UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

868 lines (831 loc) 38.9 kB
import _slicedToArray from "@babel/runtime/helpers/slicedToArray"; import _defineProperty from "@babel/runtime/helpers/defineProperty"; import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray"; 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) { _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 { makePromotedChange, mergeOverlappingByNewDocRange, rangesOverlap } from './helpers'; import { buildCharsByOffset, countWords, segmentSentences, segmentWordSpans } from './segmentText'; import { resolveThresholds } from './thresholds'; /** * 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). */ export var 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 = 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, _toConsumableArray(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 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 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 = buildCharsByOffset(blockB.node); var sentencesB = segmentSentences(charsB, locale); var charsA = buildCharsByOffset(blockA.node); var sentencesA = 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 (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 [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 = _slicedToArray(_step3.value, 2), sIdx = _step3$value[0], sentenceChanges = _step3$value[1]; var _sentence = sentencesB[sIdx]; var wordsNew = countWords(charsB, _sentence, locale); var sentA = sentencesA[sIdx]; var wordsOld = sentA ? countWords(charsA, sentA, locale) : 0; var wordSpans = 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 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(makePromotedChange(fromA, toA, fromB, toB, 'sentence')); } else { out.push.apply(out, _toConsumableArray(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 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 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, _toConsumableArray(_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(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(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(makePromotedChange(lastMatchedRowAEnd, lastMatchedRowAEnd, rowB.from, rowB.to, 'node')); } lastMatchedRowBEnd = rowB.to; continue; } // Removed row: pure deletion. if (rowA && !rowB) { out.push(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(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 rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), cellB.from, cellB.to); }); out.push.apply(out, _toConsumableArray(_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(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 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, _toConsumableArray(_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(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(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 ? [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 [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); };