@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
875 lines (837 loc) • 39.6 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.classifySmartChanges = void 0;
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray"));
var _helpers = require("./helpers");
var _segmentText = require("./segmentText");
var _thresholds = require("./thresholds");
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
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) { (0, _defineProperty2.default)(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; }
/**
* Block-first `smart` classifier.
*
* Groups changes by the top-level block they touch — like the `block` diff type — then
* classifies WITHIN each block group:
*
* 1. structural / node-type change (blockA.type !== blockB.type) → whole block
* 2. text-bearing block (paragraph/heading) → sentence / paragraph / inline
* 3. container block (list/table/layout/panel/...) → recurse into children,
* promoting the whole container when changed-child density ≥ node.ratio (with the
* rigid-child escalation: cell → row → table, column → section, item → list).
*
* Grouping on real top-level block boundaries prevents the "empty structural shell" family
* of bugs (e.g. bulletList → table rendering empty bullets).
*/
var classifySmartChanges = exports.classifySmartChanges = function classifySmartChanges(_ref) {
var changes = _ref.changes,
originalDoc = _ref.originalDoc,
newDoc = _ref.newDoc,
locale = _ref.locale,
overrides = _ref.thresholds;
if (changes.length === 0) {
return changes;
}
var thresholds = (0, _thresholds.resolveThresholds)(overrides);
var groups = groupByTopLevelBlock(changes, originalDoc, newDoc);
var result = [];
for (var _i = 0, _groups = groups; _i < _groups.length; _i++) {
var group = _groups[_i];
result.push.apply(result, (0, _toConsumableArray2.default)(classifyBlockGroup(group, originalDoc, newDoc, locale, thresholds)));
}
// Clamp to valid bounds (defensive) and coalesce overlaps.
var maxA = originalDoc.content.size;
var maxB = newDoc.content.size;
var clamped = result.map(function (change) {
return clampChange(change, maxA, maxB);
}).filter(function (change) {
return change !== null;
});
return (0, _helpers.mergeOverlappingByNewDocRange)(clamped);
};
/**
* A group of raw changes that all fall within the same top-level block, plus the resolved
* block node on each side. `blockA`/`blockB` are null for pure insertions/deletions where
* one side has no corresponding block.
*/
/** A resolved block: the node plus its OUTER bounds (before open token / after close token). */
/**
* Group changes by the top-level block (direct child of doc) they touch, aligning the A-side
* and B-side blocks a change covers. A single change can span MULTIPLE top-level blocks (e.g.
* a ReplaceStep whose slice contains several nodes); we enumerate every block it overlaps on
* both sides (`topLevelBlocksInRange`) and create one group per aligned block, so added/removed
* blocks in a multi-node replacement are never dropped. Unlike `groupChangesByBlock`, we KEEP
* each group's constituent raw changes so intra-block density can be measured.
*/
var groupByTopLevelBlock = function groupByTopLevelBlock(changes, docA, docB) {
var groups = new Map();
var ensureGroup = function ensureGroup(blockA, blockB, anchors) {
if (!blockA && !blockB) {
return null;
}
// Key by both sides so an added block (blockA=null) on the B side and a deleted block
// (blockB=null) on the A side each get their own group.
var key = "".concat(blockB ? blockB.from : 'x', ":").concat(blockA ? blockA.from : 'x');
var group = groups.get(key);
if (!group) {
group = _objectSpread({
blockA: blockA,
blockB: blockB,
changes: []
}, anchors);
groups.set(key, group);
}
return group;
};
var _iterator = _createForOfIteratorHelper(changes),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var change = _step.value;
// A single change (e.g. a ReplaceStep whose slice spans several nodes) can cover MULTIPLE
// top-level blocks on either side. Resolving only the block at `fromB` would silently drop
// the extra blocks (e.g. an added table after a replaced paragraph). So we enumerate every
// top-level block the change overlaps on BOTH sides and create a group per aligned block.
var blocksB = topLevelBlocksInRange(docB, change.fromB, change.toB);
var blocksA = topLevelBlocksInRange(docA, change.fromA, change.toA);
// Simple, common case: exactly one block on each side (or one side empty).
if (blocksB.length <= 1 && blocksA.length <= 1) {
var _blocksA$, _blocksB$;
var group = ensureGroup((_blocksA$ = blocksA[0]) !== null && _blocksA$ !== void 0 ? _blocksA$ : null, (_blocksB$ = blocksB[0]) !== null && _blocksB$ !== void 0 ? _blocksB$ : null);
group === null || group === void 0 || group.changes.push(change);
continue;
}
// Multi-block span: align blocks positionally by index. The first `min(len)` blocks are
// REPLACEMENTS (paired A↔B). Extra B-blocks are PURE INSERTIONS and extra A-blocks are
// PURE DELETIONS — and, crucially, these must NOT reuse the raw change's full A/B range
// (that range covers the paired blocks too, so an added block would claim the original
// content already owned by a paired replacement, rendering it deleted twice). Instead we
// anchor a pure insertion's A side (and a pure deletion's B side) as ZERO-WIDTH at the
// end of the last paired block on the opposite side.
var paired = Math.min(blocksA.length, blocksB.length);
for (var i = 0; i < paired; i++) {
var _group = ensureGroup(blocksA[i], blocksB[i]);
_group === null || _group === void 0 || _group.changes.push(change);
}
// Anchor for extras = end of the last paired block on the opposite side (or the start of
// the span if there were no paired blocks).
var anchorA = paired > 0 ? blocksA[paired - 1].to : change.fromA;
var anchorB = paired > 0 ? blocksB[paired - 1].to : change.fromB;
for (var _i2 = paired; _i2 < blocksB.length; _i2++) {
var _group2 = ensureGroup(null, blocksB[_i2], {
anchorA: anchorA
});
_group2 === null || _group2 === void 0 || _group2.changes.push(change);
}
for (var _i3 = paired; _i3 < blocksA.length; _i3++) {
var _group3 = ensureGroup(blocksA[_i3], null, {
anchorB: anchorB
});
_group3 === null || _group3 === void 0 || _group3.changes.push(change);
}
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return Array.from(groups.values()).sort(function (a, b) {
var _ref2, _a$blockB$from, _a$blockB, _a$blockA, _ref3, _b$blockB$from, _b$blockB, _b$blockA;
return ((_ref2 = (_a$blockB$from = (_a$blockB = a.blockB) === null || _a$blockB === void 0 ? void 0 : _a$blockB.from) !== null && _a$blockB$from !== void 0 ? _a$blockB$from : (_a$blockA = a.blockA) === null || _a$blockA === void 0 ? void 0 : _a$blockA.from) !== null && _ref2 !== void 0 ? _ref2 : 0) - ((_ref3 = (_b$blockB$from = (_b$blockB = b.blockB) === null || _b$blockB === void 0 ? void 0 : _b$blockB.from) !== null && _b$blockB$from !== void 0 ? _b$blockB$from : (_b$blockA = b.blockA) === null || _b$blockA === void 0 ? void 0 : _b$blockA.from) !== null && _ref3 !== void 0 ? _ref3 : 0);
});
};
/**
* Enumerate the top-level blocks (direct children of doc) whose outer range overlaps
* `[from, to)`. Returns each as a BlockRef. Used to split a change that spans several blocks.
*/
var topLevelBlocksInRange = function topLevelBlocksInRange(doc, from, to) {
var lo = Math.min(Math.max(from, 0), doc.content.size);
var hi = Math.min(Math.max(to, lo), doc.content.size);
var refs = [];
var offset = 0;
for (var i = 0; i < doc.childCount; i++) {
var node = doc.child(i);
var blockFrom = offset;
var blockTo = offset + node.nodeSize;
// Non-empty range: standard half-open overlap test.
if (lo < hi) {
if (blockFrom < hi && blockTo > lo) {
refs.push({
node: node,
from: blockFrom,
to: blockTo
});
}
} else if (blockFrom < lo && lo < blockTo) {
// Zero-width range (an insertion anchor): only match a block whose INTERIOR strictly
// contains the point. A point sitting exactly on a top-level block boundary
// (`blockFrom === lo`, i.e. between two sibling blocks) is a pure insertion BETWEEN
// blocks — it must resolve to NO block, otherwise `groupByTopLevelBlock` pairs the
// insertion with the following block and `classifyBlockGroup` converts the insert into a
// whole-block replacement, fabricating a phantom deletion of that untouched block.
// Interior points (a nested insertion inside a container being edited) still resolve
// their container so the classifier can recurse into it.
refs.push({
node: node,
from: blockFrom,
to: blockTo
});
}
offset = blockTo;
}
return refs;
};
var clampChange = function clampChange(change, maxA, maxB) {
var fromA = Math.max(0, Math.min(change.fromA, maxA));
var toA = Math.max(fromA, Math.min(change.toA, maxA));
var fromB = Math.max(0, Math.min(change.fromB, maxB));
var toB = Math.max(fromB, Math.min(change.toB, maxB));
if (toA === fromA && toB === fromB) {
return null;
}
return _objectSpread(_objectSpread({}, change), {}, {
fromA: fromA,
toA: toA,
fromB: fromB,
toB: toB
});
};
var TEXT_BLOCK_TYPES = new Set(['paragraph', 'heading']);
/**
* Rigid children cannot be individually deleted+re-inserted without breaking their parent's
* structure — promoting one escalates to its structural unit (see classifyContainer):
* layoutColumn → layoutSection, tableCell/tableHeader → tableRow → table.
*/
var RIGID_CHILD_TYPES = new Set(['layoutColumn', 'tableCell', 'tableHeader']);
var TABLE_TYPE = 'table';
/**
* Emit a single whole-block (node-level) change covering both sides of a group. Used for
* structural / node-type replacements and for containers dense enough to replace wholesale.
*/
var wholeBlockChange = function wholeBlockChange(blockA, blockB, changes, anchors) {
var _anchors$anchorA, _anchors$anchorA2, _anchors$anchorB, _anchors$anchorB2;
// Pure insertion (blockA === null): the A side must be a ZERO-WIDTH anchor, never the raw
// change's A range (which spans sibling blocks that were separately replaced). Same for a
// pure deletion's B side. Falling back to the change coords is the last-resort path when no
// anchor was supplied (single-block insert/delete, where the coords are already zero-width).
var fromA = blockA ? blockA.from : (_anchors$anchorA = anchors === null || anchors === void 0 ? void 0 : anchors.anchorA) !== null && _anchors$anchorA !== void 0 ? _anchors$anchorA : changes[0].fromA;
var toA = blockA ? blockA.to : (_anchors$anchorA2 = anchors === null || anchors === void 0 ? void 0 : anchors.anchorA) !== null && _anchors$anchorA2 !== void 0 ? _anchors$anchorA2 : changes[changes.length - 1].toA;
var fromB = blockB ? blockB.from : (_anchors$anchorB = anchors === null || anchors === void 0 ? void 0 : anchors.anchorB) !== null && _anchors$anchorB !== void 0 ? _anchors$anchorB : changes[0].fromB;
var toB = blockB ? blockB.to : (_anchors$anchorB2 = anchors === null || anchors === void 0 ? void 0 : anchors.anchorB) !== null && _anchors$anchorB2 !== void 0 ? _anchors$anchorB2 : changes[changes.length - 1].toB;
return (0, _helpers.makePromotedChange)(fromA, toA, fromB, toB, 'node');
};
/**
* Classify one block group. This is the single recursive decision point:
* - one side missing (pure insert/delete) → whole block
* - node type changed (para→panel, list→table, heading→para, …) → whole block
* - text-bearing block → sentence / paragraph / inline
* - container → recurse into children (with rigid escalation)
*/
var classifyBlockGroup = function classifyBlockGroup(group, originalDoc, newDoc, locale, thresholds) {
var blockA = group.blockA,
blockB = group.blockB,
changes = group.changes,
anchorA = group.anchorA,
anchorB = group.anchorB;
// Pure insertion or deletion of a whole block.
if (!blockA || !blockB) {
return [wholeBlockChange(blockA, blockB, changes, {
anchorA: anchorA,
anchorB: anchorB
})];
}
// Node-type / structural change: the whole block was replaced. No further analysis — this
// is what makes list→table, paragraph→panel, heading→paragraph "just work".
if (blockA.node.type.name !== blockB.node.type.name) {
return [wholeBlockChange(blockA, blockB, changes)];
}
// A meaningful attribute-only change is represented by a node-boundary token. Text analysis
// starts inside the block, so it cannot associate that token with a sentence and would otherwise
// drop the change. Promote the block before choosing text or container granularity.
if (!blockA.node.sameMarkup(blockB.node)) {
return [wholeBlockChange(blockA, blockB, changes)];
}
// Text-bearing block → sentence / paragraph / inline.
if (TEXT_BLOCK_TYPES.has(blockB.node.type.name)) {
return classifyTextblock(blockA, blockB, changes, locale, thresholds);
}
// Container block → measure changed-child density, promote whole container or recurse.
return classifyContainer(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds);
};
/**
* Sentence- and paragraph-level classification for a single text-bearing block.
*/
var classifyTextblock = function classifyTextblock(blockA, blockB, changes, locale, thresholds) {
var charsB = (0, _segmentText.buildCharsByOffset)(blockB.node);
var sentencesB = (0, _segmentText.segmentSentences)(charsB, locale);
var charsA = (0, _segmentText.buildCharsByOffset)(blockA.node);
var sentencesA = (0, _segmentText.segmentSentences)(charsA, locale);
// Block content starts one position after the block's outer start (open token).
var contentStartB = blockB.from + 1;
// Map each change to the sentence indices (new-doc offset space) it overlaps.
var changedSentenceIdx = new Set();
var perSentenceChanges = new Map();
var _iterator2 = _createForOfIteratorHelper(changes),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var change = _step2.value;
var fromOff = change.fromB - contentStartB;
var toOff = change.toB - contentStartB;
for (var s = 0; s < sentencesB.length; s++) {
var sentence = sentencesB[s];
if ((0, _helpers.rangesOverlap)(fromOff, Math.max(toOff, fromOff + 1), sentence.from, sentence.to)) {
changedSentenceIdx.add(s);
var list = perSentenceChanges.get(s);
if (!list) {
list = [];
perSentenceChanges.set(s, list);
}
list.push(change);
}
}
}
// Level 2: paragraph promotion.
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
var sentenceDenom = Math.max(sentencesA.length, sentencesB.length, 1);
var sentencesChanged = changedSentenceIdx.size;
if (sentencesChanged >= thresholds.paragraph.minChanged && sentencesChanged / sentenceDenom >= thresholds.paragraph.ratio) {
return [(0, _helpers.makePromotedChange)(blockA.from, blockA.to, blockB.from, blockB.to, 'paragraph')];
}
// Level 1: per-sentence promotion (else keep inline changes).
var contentStartA = blockA.from + 1;
var out = [];
var _iterator3 = _createForOfIteratorHelper(perSentenceChanges.entries()),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var _step3$value = (0, _slicedToArray2.default)(_step3.value, 2),
sIdx = _step3$value[0],
sentenceChanges = _step3$value[1];
var _sentence = sentencesB[sIdx];
var wordsNew = (0, _segmentText.countWords)(charsB, _sentence, locale);
var sentA = sentencesA[sIdx];
var wordsOld = sentA ? (0, _segmentText.countWords)(charsA, sentA, locale) : 0;
var wordSpans = (0, _segmentText.segmentWordSpans)(charsB, _sentence, locale);
var wordsChanged = 0;
var _iterator4 = _createForOfIteratorHelper(wordSpans),
_step4;
try {
var _loop = function _loop() {
var w = _step4.value;
var overlaps = sentenceChanges.some(function (c) {
return (0, _helpers.rangesOverlap)(c.fromB - contentStartB, Math.max(c.toB - contentStartB, c.fromB - contentStartB + 1), w.from, w.to);
});
if (overlaps) {
wordsChanged++;
}
};
for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
_loop();
}
} catch (err) {
_iterator4.e(err);
} finally {
_iterator4.f();
}
var wordDenom = Math.max(wordsOld, wordsNew, 1);
if (wordsChanged >= thresholds.sentence.minChanged && wordsChanged / wordDenom >= thresholds.sentence.ratio) {
var fromB = contentStartB + _sentence.from;
var toB = contentStartB + _sentence.to;
var fromA = sentA ? contentStartA + sentA.from : sentenceChanges[0].fromA;
var toA = sentA ? contentStartA + sentA.to : sentenceChanges[0].toA;
out.push((0, _helpers.makePromotedChange)(fromA, toA, fromB, toB, 'sentence'));
} else {
out.push.apply(out, (0, _toConsumableArray2.default)(sentenceChanges));
}
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
return out;
};
/** A resolved direct child of a container, with its outer bounds and index. */
/** Resolve the direct children of a container node (whose OUTER start is `blockFrom`). */
var childRefs = function childRefs(block) {
var refs = [];
var offset = block.from + 1; // content starts after the container's open token
block.node.forEach(function (child, _, index) {
refs.push({
node: child,
from: offset,
to: offset + child.nodeSize,
index: index
});
offset += child.nodeSize;
});
return refs;
};
/** Which direct-child indices of `block` are touched by any of `changes` (new-doc coords). */
var changedChildIndices = function changedChildIndices(block, children, changes) {
var changed = new Set();
var _iterator5 = _createForOfIteratorHelper(children),
_step5;
try {
var _loop2 = function _loop2() {
var child = _step5.value;
var touched = changes.some(function (c) {
return (0, _helpers.rangesOverlap)(c.fromB, Math.max(c.toB, c.fromB + 1), child.from, child.to);
});
if (touched) {
changed.add(child.index);
}
};
for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
_loop2();
}
} catch (err) {
_iterator5.e(err);
} finally {
_iterator5.f();
}
return changed;
};
/**
* A single entry in an aligned child list: a matched A/B pair, a pure insertion (a=null), or a
* pure deletion (b=null).
*/
/**
* Align a container's A-side and B-side direct children via an LCS over their serialized
* content. This is essential because index alignment (`childrenA[childB.index]`) breaks the
* moment a child is inserted or removed: every child after the insertion/deletion point would
* be mis-paired, causing added items to be classified against the wrong (or a B-side) original
* child — which corrupts the A-side coordinates and makes one list's deletions surface under a
* different block. The LCS pairs identical children as "matched" (unchanged, skipped later),
* leaving genuinely added children as B-only and removed children as A-only.
*/
var alignChildren = function alignChildren(childrenA, childrenB) {
var keyA = childrenA.map(function (c) {
return JSON.stringify(c.node.toJSON());
});
var keyB = childrenB.map(function (c) {
return JSON.stringify(c.node.toJSON());
});
var n = childrenA.length;
var m = childrenB.length;
// LCS length table.
var lcs = Array.from({
length: n + 1
}, function () {
return Array.from({
length: m + 1
}, function () {
return 0;
});
});
for (var _i4 = n - 1; _i4 >= 0; _i4--) {
for (var _j = m - 1; _j >= 0; _j--) {
lcs[_i4][_j] = keyA[_i4] === keyB[_j] ? lcs[_i4 + 1][_j + 1] + 1 : Math.max(lcs[_i4 + 1][_j], lcs[_i4][_j + 1]);
}
}
// Backtrack. Identical children become matched anchors. Runs of non-identical children
// between anchors are "zipped" positionally into modified pairs (a & b), with any leftover
// B children as pure insertions and leftover A children as pure deletions. Zipping avoids
// treating a MODIFIED child (whose content merely differs) as a delete+insert — that pairing
// lets the recursion diff inside the child (inline/sentence) instead of replacing it whole.
var out = [];
var i = 0;
var j = 0;
// Pending runs of unmatched children on each side, flushed (zipped) at each anchor / at end.
var runA = [];
var runB = [];
var flushRuns = function flushRuns() {
var shared = Math.min(runA.length, runB.length);
for (var k = 0; k < shared; k++) {
out.push({
a: runA[k],
b: runB[k]
});
}
for (var _k = shared; _k < runA.length; _k++) {
out.push({
a: runA[_k],
b: null
});
}
for (var _k2 = shared; _k2 < runB.length; _k2++) {
out.push({
a: null,
b: runB[_k2]
});
}
runA = [];
runB = [];
};
while (i < n && j < m) {
if (keyA[i] === keyB[j]) {
flushRuns();
out.push({
a: childrenA[i],
b: childrenB[j]
});
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
runA.push(childrenA[i]);
i++;
} else {
runB.push(childrenB[j]);
j++;
}
}
while (i < n) {
runA.push(childrenA[i]);
i++;
}
while (j < m) {
runB.push(childrenB[j]);
j++;
}
flushRuns();
return out;
};
/**
* Container classification with the rigid-child escalation rules:
* - list: replace whole list if changed items / items ≥ node.ratio, else recurse
* into each changed listItem's content.
* - table: replace whole table if changed cells / cells ≥ node.ratio; else, for each
* changed ROW, replace the row if changed cells / row-cells ≥ node.ratio,
* else recurse into each changed cell's content.
* - layout: replace whole section if changed columns / columns ≥ node.ratio, else
* recurse into each changed column's content.
* - generic (panel/expand/quote/...): replace whole block if changed children / children ≥
* node.ratio, else recurse into each changed child.
*/
var classifyContainer = function classifyContainer(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) {
var typeName = blockB.node.type.name;
// Tables need cell-level counting across rows; handle them specially.
if (typeName === TABLE_TYPE) {
return classifyTable(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds);
}
// Container's own markup changed (same type, differing attrs) — e.g. a panel type
// change. It touches no inner child, so the child-density check below would drop it.
// Promote the whole container to a before/after change. (Table cells take the
// `classifyChild` path, not this one.)
if (!blockA.node.sameMarkup(blockB.node)) {
return [wholeBlockChange(blockA, blockB, changes)];
}
var childrenB = childRefs(blockB);
var childrenA = childRefs(blockA);
var changed = changedChildIndices(blockB, childrenB, changes);
var denom = Math.max(childrenA.length, childrenB.length, 1);
if (changed.size / denom >= thresholds.node.ratio) {
return [wholeBlockChange(blockA, blockB, changes)];
}
// Below threshold → recurse per child, using an LCS alignment so inserted/removed children
// do not mis-pair (which previously corrupted A-side coordinates and leaked one block's
// deletions into another). Each alignment entry is one of:
// - matched (a & b): recurse to classify any intra-child changes (skipped if identical);
// - added (b only): a pure insertion, with a zero-width A anchor near its position;
// - removed (a only): a pure deletion, with a zero-width B anchor near its position.
var alignment = alignChildren(childrenA, childrenB);
var out = [];
// Running A/B anchors from the last matched pair, so pure insert/delete get sensible
// zero-width coordinates on the opposite side.
var lastMatchedAEnd = blockA.from + 1;
var lastMatchedBEnd = blockB.from + 1;
var _iterator6 = _createForOfIteratorHelper(alignment),
_step6;
try {
var _loop3 = function _loop3() {
var _step6$value = _step6.value,
a = _step6$value.a,
b = _step6$value.b;
if (a && b) {
lastMatchedAEnd = a.to;
lastMatchedBEnd = b.to;
// Identical content is left as-is by the LCS; if a change still overlaps this pair
// (e.g. marks), recurse to classify it.
var childChanges = changes.filter(function (c) {
return (0, _helpers.rangesOverlap)(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to);
});
if (childChanges.length === 0) {
return 0; // continue
}
out.push.apply(out, (0, _toConsumableArray2.default)(_classifyChild(a, b, childChanges, originalDoc, newDoc, locale, thresholds)));
return 0; // continue
}
if (b && !a) {
// Added child: pure insertion. Anchor the (empty) A side at the last matched A end.
out.push((0, _helpers.makePromotedChange)(lastMatchedAEnd, lastMatchedAEnd, b.from, b.to, 'node'));
lastMatchedBEnd = b.to;
return 0; // continue
}
if (a && !b) {
// Removed child: pure deletion. Anchor the (empty) B side at the last matched B end.
out.push((0, _helpers.makePromotedChange)(a.from, a.to, lastMatchedBEnd, lastMatchedBEnd, 'node'));
lastMatchedAEnd = a.to;
}
},
_ret;
for (_iterator6.s(); !(_step6 = _iterator6.n()).done;) {
_ret = _loop3();
if (_ret === 0) continue;
}
} catch (err) {
_iterator6.e(err);
} finally {
_iterator6.f();
}
return out;
};
/**
* Classify a table: replace the whole table when changed-cells/total-cells ≥ node.ratio;
* otherwise for each changed row, replace the row when its own changed-cells/row-cells ≥
* node.ratio, else recurse into each changed cell.
*/
var classifyTable = function classifyTable(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) {
var rowsB = childRefs(blockB);
var rowsA = childRefs(blockA);
// Total cell counts across the whole table.
var totalCellsB = 0;
var changedCellsB = 0;
var perRowChangedCells = new Map();
var _iterator7 = _createForOfIteratorHelper(rowsB),
_step7;
try {
for (_iterator7.s(); !(_step7 = _iterator7.n()).done;) {
var row = _step7.value;
var cells = childRefs(row);
totalCellsB += cells.length;
var changedCells = changedChildIndices(row, cells, changes);
if (changedCells.size > 0) {
perRowChangedCells.set(row.index, changedCells);
changedCellsB += changedCells.size;
}
}
} catch (err) {
_iterator7.e(err);
} finally {
_iterator7.f();
}
var totalCellsA = 0;
var _iterator8 = _createForOfIteratorHelper(rowsA),
_step8;
try {
for (_iterator8.s(); !(_step8 = _iterator8.n()).done;) {
var _row = _step8.value;
totalCellsA += _row.node.childCount;
}
} catch (err) {
_iterator8.e(err);
} finally {
_iterator8.f();
}
var cellDenom = Math.max(totalCellsA, totalCellsB, 1);
// Whole-table replacement.
if (changedCellsB / cellDenom >= thresholds.node.ratio) {
return [wholeBlockChange(blockA, blockB, changes)];
}
var out = [];
// LCS-align rows (mirrors classifyContainer) so an inserted/deleted row does not mis-pair
// every subsequent row by index (which would corrupt the row-level A coordinates).
var rowAlignment = alignChildren(rowsA, rowsB);
var lastMatchedRowAEnd = blockA.from + 1;
var lastMatchedRowBEnd = blockB.from + 1;
var _iterator9 = _createForOfIteratorHelper(rowAlignment),
_step9;
try {
for (_iterator9.s(); !(_step9 = _iterator9.n()).done;) {
var _step9$value = _step9.value,
rowA = _step9$value.a,
rowB = _step9$value.b;
// Added row: pure insertion (only if it actually carries changes).
if (rowB && !rowA) {
var _perRowChangedCells$g, _perRowChangedCells$g2;
if (((_perRowChangedCells$g = (_perRowChangedCells$g2 = perRowChangedCells.get(rowB.index)) === null || _perRowChangedCells$g2 === void 0 ? void 0 : _perRowChangedCells$g2.size) !== null && _perRowChangedCells$g !== void 0 ? _perRowChangedCells$g : 0) > 0) {
out.push((0, _helpers.makePromotedChange)(lastMatchedRowAEnd, lastMatchedRowAEnd, rowB.from, rowB.to, 'node'));
}
lastMatchedRowBEnd = rowB.to;
continue;
}
// Removed row: pure deletion.
if (rowA && !rowB) {
out.push((0, _helpers.makePromotedChange)(rowA.from, rowA.to, lastMatchedRowBEnd, lastMatchedRowBEnd, 'node'));
lastMatchedRowAEnd = rowA.to;
continue;
}
if (!rowA || !rowB) {
continue;
}
lastMatchedRowAEnd = rowA.to;
lastMatchedRowBEnd = rowB.to;
var _changedCells = perRowChangedCells.get(rowB.index);
if (!_changedCells || _changedCells.size === 0) {
continue;
}
var cellsB = childRefs(rowB);
// Row-level replacement.
if (_changedCells.size / Math.max(rowA.node.childCount, cellsB.length, 1) >= thresholds.node.ratio) {
out.push((0, _helpers.makePromotedChange)(rowA.from, rowA.to, rowB.from, rowB.to, 'node'));
continue;
}
// Else recurse into each changed cell's content (cell is never replaced alone). Cells are
// positionally aligned within a matched row (table columns are fixed, so a cell at index i
// on the B side corresponds to index i on the A side).
var cellsA = childRefs(rowA);
var _iterator0 = _createForOfIteratorHelper(cellsB),
_step0;
try {
var _loop4 = function _loop4() {
var _cellsA$cellB$index;
var cellB = _step0.value;
if (!_changedCells.has(cellB.index)) {
return 1; // continue
}
var cellA = (_cellsA$cellB$index = cellsA[cellB.index]) !== null && _cellsA$cellB$index !== void 0 ? _cellsA$cellB$index : null;
var cellChanges = changes.filter(function (c) {
return (0, _helpers.rangesOverlap)(c.fromB, Math.max(c.toB, c.fromB + 1), cellB.from, cellB.to);
});
out.push.apply(out, (0, _toConsumableArray2.default)(_classifyChild(cellA, cellB, cellChanges, originalDoc, newDoc, locale, thresholds)));
};
for (_iterator0.s(); !(_step0 = _iterator0.n()).done;) {
if (_loop4()) continue;
}
} catch (err) {
_iterator0.e(err);
} finally {
_iterator0.f();
}
}
} catch (err) {
_iterator9.e(err);
} finally {
_iterator9.f();
}
return out;
};
/**
* Recurse into a rigid/structural child (listItem, layoutColumn, tableCell/tableHeader) or a
* plain child block. Rigid children are containers of blocks: we recurse into THEIR children
* (paragraphs, nested lists, …) so we never delete+replace the rigid child itself. A plain
* text-bearing or container child is classified directly.
*/
var _classifyChild = function classifyChild(childA, childB, changes, originalDoc, newDoc, locale, thresholds) {
if (changes.length === 0) {
return [];
}
var typeName = childB.node.type.name;
var asBlock = function asBlock(ref) {
return ref ? {
node: ref.node,
from: ref.from,
to: ref.to
} : null;
};
// Structurally-rigid wrapper (layoutColumn / tableCell / tableHeader): the wrapper itself is
// NEVER a whole-block result — deleting+re-inserting a single column or cell would break the
// parent layout/table structure. We only reach here because the parent already decided NOT
// to promote wholesale, so we bypass `classifyContainer` (which could promote the whole
// wrapper when it is internally dense) and classify EACH of the wrapper's direct children on
// its own. This keeps the diff strictly inside the wrapper (inline / sentence / paragraph,
// or a nested container such as a list inside a table cell).
if (RIGID_CHILD_TYPES.has(typeName)) {
var blockB = asBlock(childB);
if (!blockB) {
return [];
}
var wrapperA = asBlock(childA);
var childrenB = childRefs(blockB);
var childrenA = wrapperA ? childRefs(wrapperA) : [];
var out = [];
// An attribute-only change on the wrapper itself (e.g. a table cell's
// `background`) sits on the node boundary, not inside any inner child, so the
// recursion below would emit nothing and the change would be dropped. Emit a
// whole-wrapper change instead, which also subsumes any inner content change.
if (wrapperA && !wrapperA.node.sameMarkup(blockB.node)) {
out.push((0, _helpers.makePromotedChange)(wrapperA.from, wrapperA.to, blockB.from, blockB.to, 'node'));
return out;
}
// LCS-align inner children (mirrors classifyContainer) so a paragraph inserted/deleted
// inside the cell/column does not mis-pair every subsequent inner child by index. We never
// promote the wrapper itself here — we only classify each inner child.
var alignment = alignChildren(childrenA, childrenB);
var lastMatchedAEnd = wrapperA ? wrapperA.from + 1 : blockB.from + 1;
var lastMatchedBEnd = blockB.from + 1;
var _iterator1 = _createForOfIteratorHelper(alignment),
_step1;
try {
var _loop5 = function _loop5() {
var _step1$value = _step1.value,
a = _step1$value.a,
b = _step1$value.b;
if (a && b) {
lastMatchedAEnd = a.to;
lastMatchedBEnd = b.to;
var innerChanges = changes.filter(function (c) {
return (0, _helpers.rangesOverlap)(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to);
});
if (innerChanges.length === 0) {
return 0; // continue
}
out.push.apply(out, (0, _toConsumableArray2.default)(_classifyChild(a, b, innerChanges, originalDoc, newDoc, locale, thresholds)));
return 0; // continue
}
if (b && !a) {
// Added inner child: pure insertion, zero-width A anchor at the last matched A end.
out.push((0, _helpers.makePromotedChange)(lastMatchedAEnd, lastMatchedAEnd, b.from, b.to, 'node'));
lastMatchedBEnd = b.to;
return 0; // continue
}
if (a && !b) {
// Removed inner child: pure deletion, zero-width B anchor at the last matched B end.
out.push((0, _helpers.makePromotedChange)(a.from, a.to, lastMatchedBEnd, lastMatchedBEnd, 'node'));
lastMatchedAEnd = a.to;
}
},
_ret2;
for (_iterator1.s(); !(_step1 = _iterator1.n()).done;) {
_ret2 = _loop5();
if (_ret2 === 0) continue;
}
} catch (err) {
_iterator1.e(err);
} finally {
_iterator1.f();
}
return out;
}
// A `listItem` CAN be replaced wholesale (delete + re-insert at the same position does not
// break the list's structure), so per the spec it uses the normal container rule: promote
// the whole item when its own content is dense enough, else recurse into its children.
if (typeName === 'listItem') {
var _blockB = asBlock(childB);
var blockAItem = asBlock(childA);
// Pure deletion of the item: emit the whole A-side item as deleted (zero-width B anchor at
// the item's own B-less position — anchored at the A start for lack of parent context).
if (!_blockB) {
return blockAItem ? [(0, _helpers.makePromotedChange)(blockAItem.from, blockAItem.to, blockAItem.from, blockAItem.from, 'node')] : [];
}
// Pure insertion of the item: emit the whole B-side item as inserted with a ZERO-WIDTH A
// anchor. (Using `blockB` as the A side would make the LCS compare the item against itself
// and drop the insertion.)
if (!blockAItem) {
return [(0, _helpers.makePromotedChange)(_blockB.from, _blockB.from, _blockB.from, _blockB.to, 'node')];
}
return classifyContainer(blockAItem, _blockB, changes, originalDoc, newDoc, locale, thresholds);
}
// Plain child block: classify as its own group (text or nested container).
return classifyBlockGroup({
blockA: asBlock(childA),
blockB: asBlock(childB),
changes: changes
}, originalDoc, newDoc, locale, thresholds);
};