UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

723 lines (686 loc) 31.8 kB
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 const classifySmartChanges = ({ changes, originalDoc, newDoc, locale, thresholds: overrides }) => { if (changes.length === 0) { return changes; } const thresholds = resolveThresholds(overrides); const groups = groupByTopLevelBlock(changes, originalDoc, newDoc); const result = []; for (const group of groups) { result.push(...classifyBlockGroup(group, originalDoc, newDoc, locale, thresholds)); } // Clamp to valid bounds (defensive) and coalesce overlaps. const maxA = originalDoc.content.size; const maxB = newDoc.content.size; const clamped = result.map(change => clampChange(change, maxA, maxB)).filter(change => 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. */ const groupByTopLevelBlock = (changes, docA, docB) => { const groups = new Map(); const 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. const key = `${blockB ? blockB.from : 'x'}:${blockA ? blockA.from : 'x'}`; let group = groups.get(key); if (!group) { group = { blockA, blockB, changes: [], ...anchors }; groups.set(key, group); } return group; }; for (const change of changes) { // 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. const blocksB = topLevelBlocksInRange(docB, change.fromB, change.toB); const 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$; const 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 ? 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. const paired = Math.min(blocksA.length, blocksB.length); for (let i = 0; i < paired; i++) { const group = ensureGroup(blocksA[i], blocksB[i]); group === null || group === void 0 ? 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). const anchorA = paired > 0 ? blocksA[paired - 1].to : change.fromA; const anchorB = paired > 0 ? blocksB[paired - 1].to : change.fromB; for (let i = paired; i < blocksB.length; i++) { const group = ensureGroup(null, blocksB[i], { anchorA }); group === null || group === void 0 ? void 0 : group.changes.push(change); } for (let i = paired; i < blocksA.length; i++) { const group = ensureGroup(blocksA[i], null, { anchorB }); group === null || group === void 0 ? void 0 : group.changes.push(change); } } return Array.from(groups.values()).sort((a, b) => { var _ref, _a$blockB$from, _a$blockB, _a$blockA, _ref2, _b$blockB$from, _b$blockB, _b$blockA; return ((_ref = (_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 && _ref !== void 0 ? _ref : 0) - ((_ref2 = (_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 && _ref2 !== void 0 ? _ref2 : 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. */ const topLevelBlocksInRange = (doc, from, to) => { const lo = Math.min(Math.max(from, 0), doc.content.size); const hi = Math.min(Math.max(to, lo), doc.content.size); const refs = []; let offset = 0; for (let i = 0; i < doc.childCount; i++) { const node = doc.child(i); const blockFrom = offset; const blockTo = offset + node.nodeSize; // Non-empty range: standard half-open overlap test. if (lo < hi) { if (blockFrom < hi && blockTo > lo) { refs.push({ 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, from: blockFrom, to: blockTo }); } offset = blockTo; } return refs; }; const clampChange = (change, maxA, maxB) => { const fromA = Math.max(0, Math.min(change.fromA, maxA)); const toA = Math.max(fromA, Math.min(change.toA, maxA)); const fromB = Math.max(0, Math.min(change.fromB, maxB)); const toB = Math.max(fromB, Math.min(change.toB, maxB)); if (toA === fromA && toB === fromB) { return null; } return { ...change, fromA, toA, fromB, toB }; }; const 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. */ const RIGID_CHILD_TYPES = new Set(['layoutColumn', 'tableCell', 'tableHeader']); const 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. */ const 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). const 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; const 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; const 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; const 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) */ const classifyBlockGroup = (group, originalDoc, newDoc, locale, thresholds) => { const { blockA, blockB, changes, anchorA, anchorB } = group; // Pure insertion or deletion of a whole block. if (!blockA || !blockB) { return [wholeBlockChange(blockA, blockB, changes, { anchorA, 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. */ const classifyTextblock = (blockA, blockB, changes, locale, thresholds) => { const charsB = buildCharsByOffset(blockB.node); const sentencesB = segmentSentences(charsB, locale); const charsA = buildCharsByOffset(blockA.node); const sentencesA = segmentSentences(charsA, locale); // Block content starts one position after the block's outer start (open token). const contentStartB = blockB.from + 1; // Map each change to the sentence indices (new-doc offset space) it overlaps. const changedSentenceIdx = new Set(); const perSentenceChanges = new Map(); for (const change of changes) { const fromOff = change.fromB - contentStartB; const toOff = change.toB - contentStartB; for (let s = 0; s < sentencesB.length; s++) { const sentence = sentencesB[s]; if (rangesOverlap(fromOff, Math.max(toOff, fromOff + 1), sentence.from, sentence.to)) { changedSentenceIdx.add(s); let list = perSentenceChanges.get(s); if (!list) { list = []; perSentenceChanges.set(s, list); } list.push(change); } } } // Level 2: paragraph promotion. const sentenceDenom = Math.max(sentencesA.length, sentencesB.length, 1); const 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). const contentStartA = blockA.from + 1; const out = []; for (const [sIdx, sentenceChanges] of perSentenceChanges.entries()) { const sentence = sentencesB[sIdx]; const wordsNew = countWords(charsB, sentence, locale); const sentA = sentencesA[sIdx]; const wordsOld = sentA ? countWords(charsA, sentA, locale) : 0; const wordSpans = segmentWordSpans(charsB, sentence, locale); let wordsChanged = 0; for (const w of wordSpans) { const overlaps = sentenceChanges.some(c => rangesOverlap(c.fromB - contentStartB, Math.max(c.toB - contentStartB, c.fromB - contentStartB + 1), w.from, w.to)); if (overlaps) { wordsChanged++; } } const wordDenom = Math.max(wordsOld, wordsNew, 1); if (wordsChanged >= thresholds.sentence.minChanged && wordsChanged / wordDenom >= thresholds.sentence.ratio) { const fromB = contentStartB + sentence.from; const toB = contentStartB + sentence.to; const fromA = sentA ? contentStartA + sentA.from : sentenceChanges[0].fromA; const toA = sentA ? contentStartA + sentA.to : sentenceChanges[0].toA; out.push(makePromotedChange(fromA, toA, fromB, toB, 'sentence')); } else { out.push(...sentenceChanges); } } 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`). */ const childRefs = block => { const refs = []; let offset = block.from + 1; // content starts after the container's open token block.node.forEach((child, _, index) => { refs.push({ node: child, from: offset, to: offset + child.nodeSize, index }); offset += child.nodeSize; }); return refs; }; /** Which direct-child indices of `block` are touched by any of `changes` (new-doc coords). */ const changedChildIndices = (block, children, changes) => { const changed = new Set(); for (const child of children) { const touched = changes.some(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), child.from, child.to)); if (touched) { changed.add(child.index); } } 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. */ const alignChildren = (childrenA, childrenB) => { const keyA = childrenA.map(c => JSON.stringify(c.node.toJSON())); const keyB = childrenB.map(c => JSON.stringify(c.node.toJSON())); const n = childrenA.length; const m = childrenB.length; // LCS length table. const lcs = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }, () => 0)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { lcs[i][j] = keyA[i] === keyB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][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. const out = []; let i = 0; let j = 0; // Pending runs of unmatched children on each side, flushed (zipped) at each anchor / at end. let runA = []; let runB = []; const flushRuns = () => { const shared = Math.min(runA.length, runB.length); for (let k = 0; k < shared; k++) { out.push({ a: runA[k], b: runB[k] }); } for (let k = shared; k < runA.length; k++) { out.push({ a: runA[k], b: null }); } for (let k = shared; k < runB.length; k++) { out.push({ a: null, b: runB[k] }); } 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. */ const classifyContainer = (blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) => { const 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)]; } const childrenB = childRefs(blockB); const childrenA = childRefs(blockA); const changed = changedChildIndices(blockB, childrenB, changes); const 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. const alignment = alignChildren(childrenA, childrenB); const out = []; // Running A/B anchors from the last matched pair, so pure insert/delete get sensible // zero-width coordinates on the opposite side. let lastMatchedAEnd = blockA.from + 1; let lastMatchedBEnd = blockB.from + 1; for (const { a, b } of alignment) { 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. const childChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to)); if (childChanges.length === 0) { continue; } out.push(...classifyChild(a, b, childChanges, originalDoc, newDoc, locale, thresholds)); 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; 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; } } 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. */ const classifyTable = (blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) => { const rowsB = childRefs(blockB); const rowsA = childRefs(blockA); // Total cell counts across the whole table. let totalCellsB = 0; let changedCellsB = 0; const perRowChangedCells = new Map(); for (const row of rowsB) { const cells = childRefs(row); totalCellsB += cells.length; const changedCells = changedChildIndices(row, cells, changes); if (changedCells.size > 0) { perRowChangedCells.set(row.index, changedCells); changedCellsB += changedCells.size; } } let totalCellsA = 0; for (const row of rowsA) { totalCellsA += row.node.childCount; } const cellDenom = Math.max(totalCellsA, totalCellsB, 1); // Whole-table replacement. if (changedCellsB / cellDenom >= thresholds.node.ratio) { return [wholeBlockChange(blockA, blockB, changes)]; } const 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). const rowAlignment = alignChildren(rowsA, rowsB); let lastMatchedRowAEnd = blockA.from + 1; let lastMatchedRowBEnd = blockB.from + 1; for (const { a: rowA, b: rowB } of rowAlignment) { // 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; const changedCells = perRowChangedCells.get(rowB.index); if (!changedCells || changedCells.size === 0) { continue; } const 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). const cellsA = childRefs(rowA); for (const cellB of cellsB) { var _cellsA$cellB$index; if (!changedCells.has(cellB.index)) { continue; } const cellA = (_cellsA$cellB$index = cellsA[cellB.index]) !== null && _cellsA$cellB$index !== void 0 ? _cellsA$cellB$index : null; const cellChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), cellB.from, cellB.to)); out.push(...classifyChild(cellA, cellB, cellChanges, originalDoc, newDoc, locale, thresholds)); } } 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. */ const classifyChild = (childA, childB, changes, originalDoc, newDoc, locale, thresholds) => { if (changes.length === 0) { return []; } const typeName = childB.node.type.name; const asBlock = ref => 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)) { const blockB = asBlock(childB); if (!blockB) { return []; } const wrapperA = asBlock(childA); const childrenB = childRefs(blockB); const childrenA = wrapperA ? childRefs(wrapperA) : []; const 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. const alignment = alignChildren(childrenA, childrenB); let lastMatchedAEnd = wrapperA ? wrapperA.from + 1 : blockB.from + 1; let lastMatchedBEnd = blockB.from + 1; for (const { a, b } of alignment) { if (a && b) { lastMatchedAEnd = a.to; lastMatchedBEnd = b.to; const innerChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to)); if (innerChanges.length === 0) { continue; } out.push(...classifyChild(a, b, innerChanges, originalDoc, newDoc, locale, thresholds)); 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; 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; } } 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') { const blockB = asBlock(childB); const 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 }, originalDoc, newDoc, locale, thresholds); };