@atlaskit/editor-plugin-show-diff 10.1.23 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/calculate-diff/package.json +8 -0
  3. package/dist/cjs/entry-points/calculate-diff.js +12 -0
  4. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
  5. package/dist/cjs/pm-plugins/calculateDiff/computeDiffChanges.js +112 -0
  6. package/dist/cjs/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -2
  7. package/dist/cjs/pm-plugins/calculateDiff/smart/classifySmartChanges.js +841 -0
  8. package/dist/cjs/pm-plugins/calculateDiff/smart/helpers.js +135 -0
  9. package/dist/cjs/pm-plugins/calculateDiff/smart/segmentText.js +331 -0
  10. package/dist/cjs/pm-plugins/calculateDiff/smart/thresholds.js +46 -0
  11. package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  12. package/dist/cjs/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  13. package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  14. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
  15. package/dist/cjs/pm-plugins/decorations/decorationKeys.js +8 -5
  16. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
  17. package/dist/cjs/pm-plugins/getScrollableDecorations.js +6 -6
  18. package/dist/cjs/pm-plugins/isExtendedEnabled.js +20 -0
  19. package/dist/cjs/pm-plugins/main.js +21 -16
  20. package/dist/es2019/entry-points/calculate-diff.js +2 -0
  21. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +119 -29
  22. package/dist/es2019/pm-plugins/calculateDiff/computeDiffChanges.js +94 -0
  23. package/dist/es2019/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
  24. package/dist/es2019/pm-plugins/calculateDiff/smart/classifySmartChanges.js +689 -0
  25. package/dist/es2019/pm-plugins/calculateDiff/smart/helpers.js +106 -0
  26. package/dist/es2019/pm-plugins/calculateDiff/smart/segmentText.js +283 -0
  27. package/dist/es2019/pm-plugins/calculateDiff/smart/thresholds.js +45 -0
  28. package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  29. package/dist/es2019/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  30. package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  31. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +31 -16
  32. package/dist/es2019/pm-plugins/decorations/decorationKeys.js +9 -5
  33. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -34
  34. package/dist/es2019/pm-plugins/getScrollableDecorations.js +6 -6
  35. package/dist/es2019/pm-plugins/isExtendedEnabled.js +12 -0
  36. package/dist/es2019/pm-plugins/main.js +21 -16
  37. package/dist/esm/entry-points/calculate-diff.js +2 -0
  38. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
  39. package/dist/esm/pm-plugins/calculateDiff/computeDiffChanges.js +107 -0
  40. package/dist/esm/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
  41. package/dist/esm/pm-plugins/calculateDiff/smart/classifySmartChanges.js +834 -0
  42. package/dist/esm/pm-plugins/calculateDiff/smart/helpers.js +128 -0
  43. package/dist/esm/pm-plugins/calculateDiff/smart/segmentText.js +325 -0
  44. package/dist/esm/pm-plugins/calculateDiff/smart/thresholds.js +39 -0
  45. package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
  46. package/dist/esm/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
  47. package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
  48. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
  49. package/dist/esm/pm-plugins/decorations/decorationKeys.js +9 -5
  50. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
  51. package/dist/esm/pm-plugins/getScrollableDecorations.js +6 -6
  52. package/dist/esm/pm-plugins/isExtendedEnabled.js +14 -0
  53. package/dist/esm/pm-plugins/main.js +21 -16
  54. package/dist/types/entry-points/calculate-diff.d.ts +2 -0
  55. package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
  56. package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +3 -1
  57. package/dist/types/pm-plugins/calculateDiff/computeDiffChanges.d.ts +46 -0
  58. package/dist/types/pm-plugins/calculateDiff/groupChangesByBlock.d.ts +9 -0
  59. package/dist/types/pm-plugins/calculateDiff/smart/classifySmartChanges.d.ts +26 -0
  60. package/dist/types/pm-plugins/calculateDiff/smart/helpers.d.ts +44 -0
  61. package/dist/types/pm-plugins/calculateDiff/smart/segmentText.d.ts +32 -0
  62. package/dist/types/pm-plugins/calculateDiff/smart/thresholds.d.ts +44 -0
  63. package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +3 -2
  64. package/dist/types/pm-plugins/decorations/createChangedRowDecorationWidgets.d.ts +3 -2
  65. package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
  66. package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +4 -2
  67. package/dist/types/pm-plugins/decorations/decorationKeys.d.ts +5 -3
  68. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +6 -4
  69. package/dist/types/pm-plugins/getScrollableDecorations.d.ts +2 -1
  70. package/dist/types/pm-plugins/isExtendedEnabled.d.ts +11 -0
  71. package/dist/types/pm-plugins/main.d.ts +11 -1
  72. package/dist/types/showDiffPluginType.d.ts +20 -1
  73. package/docs/smart-diff-design.md +240 -0
  74. package/package.json +8 -3
@@ -0,0 +1,689 @@
1
+ import { makePromotedChange, mergeOverlappingByNewDocRange, rangesOverlap } from './helpers';
2
+ import { buildCharsByOffset, countWords, segmentSentences, segmentWordSpans } from './segmentText';
3
+ import { resolveThresholds } from './thresholds';
4
+ /**
5
+ * Block-first `smart` classifier.
6
+ *
7
+ * Groups changes by the top-level block they touch — like the `block` diff type — then
8
+ * classifies WITHIN each block group:
9
+ *
10
+ * 1. structural / node-type change (blockA.type !== blockB.type) → whole block
11
+ * 2. text-bearing block (paragraph/heading) → sentence / paragraph / inline
12
+ * 3. container block (list/table/layout/panel/...) → recurse into children,
13
+ * promoting the whole container when changed-child density ≥ node.ratio (with the
14
+ * rigid-child escalation: cell → row → table, column → section, item → list).
15
+ *
16
+ * Grouping on real top-level block boundaries prevents the "empty structural shell" family
17
+ * of bugs (e.g. bulletList → table rendering empty bullets).
18
+ */
19
+ export const classifySmartChanges = ({
20
+ changes,
21
+ originalDoc,
22
+ newDoc,
23
+ locale,
24
+ thresholds: overrides
25
+ }) => {
26
+ if (changes.length === 0) {
27
+ return changes;
28
+ }
29
+ const thresholds = resolveThresholds(overrides);
30
+ const groups = groupByTopLevelBlock(changes, originalDoc, newDoc);
31
+ const result = [];
32
+ for (const group of groups) {
33
+ result.push(...classifyBlockGroup(group, originalDoc, newDoc, locale, thresholds));
34
+ }
35
+
36
+ // Clamp to valid bounds (defensive) and coalesce overlaps.
37
+ const maxA = originalDoc.content.size;
38
+ const maxB = newDoc.content.size;
39
+ const clamped = result.map(change => clampChange(change, maxA, maxB)).filter(change => change !== null);
40
+ return mergeOverlappingByNewDocRange(clamped);
41
+ };
42
+
43
+ /**
44
+ * A group of raw changes that all fall within the same top-level block, plus the resolved
45
+ * block node on each side. `blockA`/`blockB` are null for pure insertions/deletions where
46
+ * one side has no corresponding block.
47
+ */
48
+
49
+ /** A resolved block: the node plus its OUTER bounds (before open token / after close token). */
50
+
51
+ /**
52
+ * Group changes by the top-level block (direct child of doc) they touch, aligning the A-side
53
+ * and B-side blocks a change covers. A single change can span MULTIPLE top-level blocks (e.g.
54
+ * a ReplaceStep whose slice contains several nodes); we enumerate every block it overlaps on
55
+ * both sides (`topLevelBlocksInRange`) and create one group per aligned block, so added/removed
56
+ * blocks in a multi-node replacement are never dropped. Unlike `groupChangesByBlock`, we KEEP
57
+ * each group's constituent raw changes so intra-block density can be measured.
58
+ */
59
+ const groupByTopLevelBlock = (changes, docA, docB) => {
60
+ const groups = new Map();
61
+ const ensureGroup = (blockA, blockB, anchors) => {
62
+ if (!blockA && !blockB) {
63
+ return null;
64
+ }
65
+ // Key by both sides so an added block (blockA=null) on the B side and a deleted block
66
+ // (blockB=null) on the A side each get their own group.
67
+ const key = `${blockB ? blockB.from : 'x'}:${blockA ? blockA.from : 'x'}`;
68
+ let group = groups.get(key);
69
+ if (!group) {
70
+ group = {
71
+ blockA,
72
+ blockB,
73
+ changes: [],
74
+ ...anchors
75
+ };
76
+ groups.set(key, group);
77
+ }
78
+ return group;
79
+ };
80
+ for (const change of changes) {
81
+ // A single change (e.g. a ReplaceStep whose slice spans several nodes) can cover MULTIPLE
82
+ // top-level blocks on either side. Resolving only the block at `fromB` would silently drop
83
+ // the extra blocks (e.g. an added table after a replaced paragraph). So we enumerate every
84
+ // top-level block the change overlaps on BOTH sides and create a group per aligned block.
85
+ const blocksB = topLevelBlocksInRange(docB, change.fromB, change.toB);
86
+ const blocksA = topLevelBlocksInRange(docA, change.fromA, change.toA);
87
+
88
+ // Simple, common case: exactly one block on each side (or one side empty).
89
+ if (blocksB.length <= 1 && blocksA.length <= 1) {
90
+ var _blocksA$, _blocksB$;
91
+ const group = ensureGroup((_blocksA$ = blocksA[0]) !== null && _blocksA$ !== void 0 ? _blocksA$ : null, (_blocksB$ = blocksB[0]) !== null && _blocksB$ !== void 0 ? _blocksB$ : null);
92
+ group === null || group === void 0 ? void 0 : group.changes.push(change);
93
+ continue;
94
+ }
95
+
96
+ // Multi-block span: align blocks positionally by index. The first `min(len)` blocks are
97
+ // REPLACEMENTS (paired A↔B). Extra B-blocks are PURE INSERTIONS and extra A-blocks are
98
+ // PURE DELETIONS — and, crucially, these must NOT reuse the raw change's full A/B range
99
+ // (that range covers the paired blocks too, so an added block would claim the original
100
+ // content already owned by a paired replacement, rendering it deleted twice). Instead we
101
+ // anchor a pure insertion's A side (and a pure deletion's B side) as ZERO-WIDTH at the
102
+ // end of the last paired block on the opposite side.
103
+ const paired = Math.min(blocksA.length, blocksB.length);
104
+ for (let i = 0; i < paired; i++) {
105
+ const group = ensureGroup(blocksA[i], blocksB[i]);
106
+ group === null || group === void 0 ? void 0 : group.changes.push(change);
107
+ }
108
+ // Anchor for extras = end of the last paired block on the opposite side (or the start of
109
+ // the span if there were no paired blocks).
110
+ const anchorA = paired > 0 ? blocksA[paired - 1].to : change.fromA;
111
+ const anchorB = paired > 0 ? blocksB[paired - 1].to : change.fromB;
112
+ for (let i = paired; i < blocksB.length; i++) {
113
+ const group = ensureGroup(null, blocksB[i], {
114
+ anchorA
115
+ });
116
+ group === null || group === void 0 ? void 0 : group.changes.push(change);
117
+ }
118
+ for (let i = paired; i < blocksA.length; i++) {
119
+ const group = ensureGroup(blocksA[i], null, {
120
+ anchorB
121
+ });
122
+ group === null || group === void 0 ? void 0 : group.changes.push(change);
123
+ }
124
+ }
125
+ return Array.from(groups.values()).sort((a, b) => {
126
+ var _ref, _a$blockB$from, _a$blockB, _a$blockA, _ref2, _b$blockB$from, _b$blockB, _b$blockA;
127
+ 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);
128
+ });
129
+ };
130
+
131
+ /**
132
+ * Enumerate the top-level blocks (direct children of doc) whose outer range overlaps
133
+ * `[from, to)`. Returns each as a BlockRef. Used to split a change that spans several blocks.
134
+ */
135
+ const topLevelBlocksInRange = (doc, from, to) => {
136
+ const lo = Math.min(Math.max(from, 0), doc.content.size);
137
+ const hi = Math.min(Math.max(to, lo), doc.content.size);
138
+ const refs = [];
139
+ let offset = 0;
140
+ for (let i = 0; i < doc.childCount; i++) {
141
+ const node = doc.child(i);
142
+ const blockFrom = offset;
143
+ const blockTo = offset + node.nodeSize;
144
+ // Overlap test; include a zero-width touch at the block start (pure insertion point).
145
+ if (blockFrom < hi && blockTo > lo) {
146
+ refs.push({
147
+ node,
148
+ from: blockFrom,
149
+ to: blockTo
150
+ });
151
+ } else if (blockFrom === lo && blockFrom === hi) {
152
+ refs.push({
153
+ node,
154
+ from: blockFrom,
155
+ to: blockTo
156
+ });
157
+ }
158
+ offset = blockTo;
159
+ }
160
+ return refs;
161
+ };
162
+ const clampChange = (change, maxA, maxB) => {
163
+ const fromA = Math.max(0, Math.min(change.fromA, maxA));
164
+ const toA = Math.max(fromA, Math.min(change.toA, maxA));
165
+ const fromB = Math.max(0, Math.min(change.fromB, maxB));
166
+ const toB = Math.max(fromB, Math.min(change.toB, maxB));
167
+ if (toA === fromA && toB === fromB) {
168
+ return null;
169
+ }
170
+ return {
171
+ ...change,
172
+ fromA,
173
+ toA,
174
+ fromB,
175
+ toB
176
+ };
177
+ };
178
+ const TEXT_BLOCK_TYPES = new Set(['paragraph', 'heading']);
179
+
180
+ /**
181
+ * Rigid children cannot be individually deleted+re-inserted without breaking their parent's
182
+ * structure — promoting one escalates to its structural unit (see classifyContainer):
183
+ * layoutColumn → layoutSection, tableCell/tableHeader → tableRow → table.
184
+ */
185
+ const RIGID_CHILD_TYPES = new Set(['layoutColumn', 'tableCell', 'tableHeader']);
186
+ const TABLE_TYPE = 'table';
187
+
188
+ /**
189
+ * Emit a single whole-block (node-level) change covering both sides of a group. Used for
190
+ * structural / node-type replacements and for containers dense enough to replace wholesale.
191
+ */
192
+ const wholeBlockChange = (blockA, blockB, changes, anchors) => {
193
+ var _anchors$anchorA, _anchors$anchorA2, _anchors$anchorB, _anchors$anchorB2;
194
+ // Pure insertion (blockA === null): the A side must be a ZERO-WIDTH anchor, never the raw
195
+ // change's A range (which spans sibling blocks that were separately replaced). Same for a
196
+ // pure deletion's B side. Falling back to the change coords is the last-resort path when no
197
+ // anchor was supplied (single-block insert/delete, where the coords are already zero-width).
198
+ 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;
199
+ 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;
200
+ 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;
201
+ 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;
202
+ return makePromotedChange(fromA, toA, fromB, toB, 'node');
203
+ };
204
+
205
+ /**
206
+ * Classify one block group. This is the single recursive decision point:
207
+ * - one side missing (pure insert/delete) → whole block
208
+ * - node type changed (para→panel, list→table, heading→para, …) → whole block
209
+ * - text-bearing block → sentence / paragraph / inline
210
+ * - container → recurse into children (with rigid escalation)
211
+ */
212
+ const classifyBlockGroup = (group, originalDoc, newDoc, locale, thresholds) => {
213
+ const {
214
+ blockA,
215
+ blockB,
216
+ changes,
217
+ anchorA,
218
+ anchorB
219
+ } = group;
220
+
221
+ // Pure insertion or deletion of a whole block.
222
+ if (!blockA || !blockB) {
223
+ return [wholeBlockChange(blockA, blockB, changes, {
224
+ anchorA,
225
+ anchorB
226
+ })];
227
+ }
228
+
229
+ // Node-type / structural change: the whole block was replaced. No further analysis — this
230
+ // is what makes list→table, paragraph→panel, heading→paragraph "just work".
231
+ if (blockA.node.type.name !== blockB.node.type.name) {
232
+ return [wholeBlockChange(blockA, blockB, changes)];
233
+ }
234
+
235
+ // Text-bearing block → sentence / paragraph / inline.
236
+ if (TEXT_BLOCK_TYPES.has(blockB.node.type.name)) {
237
+ return classifyTextblock(blockA, blockB, changes, locale, thresholds);
238
+ }
239
+
240
+ // Container block → measure changed-child density, promote whole container or recurse.
241
+ return classifyContainer(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds);
242
+ };
243
+
244
+ /**
245
+ * Sentence- and paragraph-level classification for a single text-bearing block.
246
+ */
247
+ const classifyTextblock = (blockA, blockB, changes, locale, thresholds) => {
248
+ const charsB = buildCharsByOffset(blockB.node);
249
+ const sentencesB = segmentSentences(charsB, locale);
250
+ const charsA = buildCharsByOffset(blockA.node);
251
+ const sentencesA = segmentSentences(charsA, locale);
252
+
253
+ // Block content starts one position after the block's outer start (open token).
254
+ const contentStartB = blockB.from + 1;
255
+
256
+ // Map each change to the sentence indices (new-doc offset space) it overlaps.
257
+ const changedSentenceIdx = new Set();
258
+ const perSentenceChanges = new Map();
259
+ for (const change of changes) {
260
+ const fromOff = change.fromB - contentStartB;
261
+ const toOff = change.toB - contentStartB;
262
+ for (let s = 0; s < sentencesB.length; s++) {
263
+ const sentence = sentencesB[s];
264
+ if (rangesOverlap(fromOff, Math.max(toOff, fromOff + 1), sentence.from, sentence.to)) {
265
+ changedSentenceIdx.add(s);
266
+ let list = perSentenceChanges.get(s);
267
+ if (!list) {
268
+ list = [];
269
+ perSentenceChanges.set(s, list);
270
+ }
271
+ list.push(change);
272
+ }
273
+ }
274
+ }
275
+
276
+ // Level 2: paragraph promotion.
277
+ const sentenceDenom = Math.max(sentencesA.length, sentencesB.length, 1);
278
+ const sentencesChanged = changedSentenceIdx.size;
279
+ if (sentencesChanged >= thresholds.paragraph.minChanged && sentencesChanged / sentenceDenom >= thresholds.paragraph.ratio) {
280
+ return [makePromotedChange(blockA.from, blockA.to, blockB.from, blockB.to, 'paragraph')];
281
+ }
282
+
283
+ // Level 1: per-sentence promotion (else keep inline changes).
284
+ const contentStartA = blockA.from + 1;
285
+ const out = [];
286
+ for (const [sIdx, sentenceChanges] of perSentenceChanges.entries()) {
287
+ const sentence = sentencesB[sIdx];
288
+ const wordsNew = countWords(charsB, sentence, locale);
289
+ const sentA = sentencesA[sIdx];
290
+ const wordsOld = sentA ? countWords(charsA, sentA, locale) : 0;
291
+ const wordSpans = segmentWordSpans(charsB, sentence, locale);
292
+ let wordsChanged = 0;
293
+ for (const w of wordSpans) {
294
+ const overlaps = sentenceChanges.some(c => rangesOverlap(c.fromB - contentStartB, Math.max(c.toB - contentStartB, c.fromB - contentStartB + 1), w.from, w.to));
295
+ if (overlaps) {
296
+ wordsChanged++;
297
+ }
298
+ }
299
+ const wordDenom = Math.max(wordsOld, wordsNew, 1);
300
+ if (wordsChanged >= thresholds.sentence.minChanged && wordsChanged / wordDenom >= thresholds.sentence.ratio) {
301
+ const fromB = contentStartB + sentence.from;
302
+ const toB = contentStartB + sentence.to;
303
+ const fromA = sentA ? contentStartA + sentA.from : sentenceChanges[0].fromA;
304
+ const toA = sentA ? contentStartA + sentA.to : sentenceChanges[0].toA;
305
+ out.push(makePromotedChange(fromA, toA, fromB, toB, 'sentence'));
306
+ } else {
307
+ out.push(...sentenceChanges);
308
+ }
309
+ }
310
+ return out;
311
+ };
312
+
313
+ /** A resolved direct child of a container, with its outer bounds and index. */
314
+
315
+ /** Resolve the direct children of a container node (whose OUTER start is `blockFrom`). */
316
+ const childRefs = block => {
317
+ const refs = [];
318
+ let offset = block.from + 1; // content starts after the container's open token
319
+ block.node.forEach((child, _, index) => {
320
+ refs.push({
321
+ node: child,
322
+ from: offset,
323
+ to: offset + child.nodeSize,
324
+ index
325
+ });
326
+ offset += child.nodeSize;
327
+ });
328
+ return refs;
329
+ };
330
+
331
+ /** Which direct-child indices of `block` are touched by any of `changes` (new-doc coords). */
332
+ const changedChildIndices = (block, children, changes) => {
333
+ const changed = new Set();
334
+ for (const child of children) {
335
+ const touched = changes.some(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), child.from, child.to));
336
+ if (touched) {
337
+ changed.add(child.index);
338
+ }
339
+ }
340
+ return changed;
341
+ };
342
+
343
+ /**
344
+ * A single entry in an aligned child list: a matched A/B pair, a pure insertion (a=null), or a
345
+ * pure deletion (b=null).
346
+ */
347
+
348
+ /**
349
+ * Align a container's A-side and B-side direct children via an LCS over their serialized
350
+ * content. This is essential because index alignment (`childrenA[childB.index]`) breaks the
351
+ * moment a child is inserted or removed: every child after the insertion/deletion point would
352
+ * be mis-paired, causing added items to be classified against the wrong (or a B-side) original
353
+ * child — which corrupts the A-side coordinates and makes one list's deletions surface under a
354
+ * different block. The LCS pairs identical children as "matched" (unchanged, skipped later),
355
+ * leaving genuinely added children as B-only and removed children as A-only.
356
+ */
357
+ const alignChildren = (childrenA, childrenB) => {
358
+ const keyA = childrenA.map(c => JSON.stringify(c.node.toJSON()));
359
+ const keyB = childrenB.map(c => JSON.stringify(c.node.toJSON()));
360
+ const n = childrenA.length;
361
+ const m = childrenB.length;
362
+ // LCS length table.
363
+ const lcs = Array.from({
364
+ length: n + 1
365
+ }, () => Array.from({
366
+ length: m + 1
367
+ }, () => 0));
368
+ for (let i = n - 1; i >= 0; i--) {
369
+ for (let j = m - 1; j >= 0; j--) {
370
+ lcs[i][j] = keyA[i] === keyB[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
371
+ }
372
+ }
373
+ // Backtrack. Identical children become matched anchors. Runs of non-identical children
374
+ // between anchors are "zipped" positionally into modified pairs (a & b), with any leftover
375
+ // B children as pure insertions and leftover A children as pure deletions. Zipping avoids
376
+ // treating a MODIFIED child (whose content merely differs) as a delete+insert — that pairing
377
+ // lets the recursion diff inside the child (inline/sentence) instead of replacing it whole.
378
+ const out = [];
379
+ let i = 0;
380
+ let j = 0;
381
+ // Pending runs of unmatched children on each side, flushed (zipped) at each anchor / at end.
382
+ let runA = [];
383
+ let runB = [];
384
+ const flushRuns = () => {
385
+ const shared = Math.min(runA.length, runB.length);
386
+ for (let k = 0; k < shared; k++) {
387
+ out.push({
388
+ a: runA[k],
389
+ b: runB[k]
390
+ });
391
+ }
392
+ for (let k = shared; k < runA.length; k++) {
393
+ out.push({
394
+ a: runA[k],
395
+ b: null
396
+ });
397
+ }
398
+ for (let k = shared; k < runB.length; k++) {
399
+ out.push({
400
+ a: null,
401
+ b: runB[k]
402
+ });
403
+ }
404
+ runA = [];
405
+ runB = [];
406
+ };
407
+ while (i < n && j < m) {
408
+ if (keyA[i] === keyB[j]) {
409
+ flushRuns();
410
+ out.push({
411
+ a: childrenA[i],
412
+ b: childrenB[j]
413
+ });
414
+ i++;
415
+ j++;
416
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
417
+ runA.push(childrenA[i]);
418
+ i++;
419
+ } else {
420
+ runB.push(childrenB[j]);
421
+ j++;
422
+ }
423
+ }
424
+ while (i < n) {
425
+ runA.push(childrenA[i]);
426
+ i++;
427
+ }
428
+ while (j < m) {
429
+ runB.push(childrenB[j]);
430
+ j++;
431
+ }
432
+ flushRuns();
433
+ return out;
434
+ };
435
+
436
+ /**
437
+ * Container classification with the rigid-child escalation rules:
438
+ * - list: replace whole list if changed items / items ≥ node.ratio, else recurse
439
+ * into each changed listItem's content.
440
+ * - table: replace whole table if changed cells / cells ≥ node.ratio; else, for each
441
+ * changed ROW, replace the row if changed cells / row-cells ≥ node.ratio,
442
+ * else recurse into each changed cell's content.
443
+ * - layout: replace whole section if changed columns / columns ≥ node.ratio, else
444
+ * recurse into each changed column's content.
445
+ * - generic (panel/expand/quote/...): replace whole block if changed children / children ≥
446
+ * node.ratio, else recurse into each changed child.
447
+ */
448
+ const classifyContainer = (blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) => {
449
+ const typeName = blockB.node.type.name;
450
+
451
+ // Tables need cell-level counting across rows; handle them specially.
452
+ if (typeName === TABLE_TYPE) {
453
+ return classifyTable(blockA, blockB, changes, originalDoc, newDoc, locale, thresholds);
454
+ }
455
+ const childrenB = childRefs(blockB);
456
+ const childrenA = childRefs(blockA);
457
+ const changed = changedChildIndices(blockB, childrenB, changes);
458
+ const denom = Math.max(childrenA.length, childrenB.length, 1);
459
+ if (changed.size / denom >= thresholds.node.ratio) {
460
+ return [wholeBlockChange(blockA, blockB, changes)];
461
+ }
462
+
463
+ // Below threshold → recurse per child, using an LCS alignment so inserted/removed children
464
+ // do not mis-pair (which previously corrupted A-side coordinates and leaked one block's
465
+ // deletions into another). Each alignment entry is one of:
466
+ // - matched (a & b): recurse to classify any intra-child changes (skipped if identical);
467
+ // - added (b only): a pure insertion, with a zero-width A anchor near its position;
468
+ // - removed (a only): a pure deletion, with a zero-width B anchor near its position.
469
+ const alignment = alignChildren(childrenA, childrenB);
470
+ const out = [];
471
+ // Running A/B anchors from the last matched pair, so pure insert/delete get sensible
472
+ // zero-width coordinates on the opposite side.
473
+ let lastMatchedAEnd = blockA.from + 1;
474
+ let lastMatchedBEnd = blockB.from + 1;
475
+ for (const {
476
+ a,
477
+ b
478
+ } of alignment) {
479
+ if (a && b) {
480
+ lastMatchedAEnd = a.to;
481
+ lastMatchedBEnd = b.to;
482
+ // Identical content is left as-is by the LCS; if a change still overlaps this pair
483
+ // (e.g. marks), recurse to classify it.
484
+ const childChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to));
485
+ if (childChanges.length === 0) {
486
+ continue;
487
+ }
488
+ out.push(...classifyChild(a, b, childChanges, originalDoc, newDoc, locale, thresholds));
489
+ continue;
490
+ }
491
+ if (b && !a) {
492
+ // Added child: pure insertion. Anchor the (empty) A side at the last matched A end.
493
+ out.push(makePromotedChange(lastMatchedAEnd, lastMatchedAEnd, b.from, b.to, 'node'));
494
+ lastMatchedBEnd = b.to;
495
+ continue;
496
+ }
497
+ if (a && !b) {
498
+ // Removed child: pure deletion. Anchor the (empty) B side at the last matched B end.
499
+ out.push(makePromotedChange(a.from, a.to, lastMatchedBEnd, lastMatchedBEnd, 'node'));
500
+ lastMatchedAEnd = a.to;
501
+ }
502
+ }
503
+ return out;
504
+ };
505
+
506
+ /**
507
+ * Classify a table: replace the whole table when changed-cells/total-cells ≥ node.ratio;
508
+ * otherwise for each changed row, replace the row when its own changed-cells/row-cells ≥
509
+ * node.ratio, else recurse into each changed cell.
510
+ */
511
+ const classifyTable = (blockA, blockB, changes, originalDoc, newDoc, locale, thresholds) => {
512
+ const rowsB = childRefs(blockB);
513
+ const rowsA = childRefs(blockA);
514
+
515
+ // Total cell counts across the whole table.
516
+ let totalCellsB = 0;
517
+ let changedCellsB = 0;
518
+ const perRowChangedCells = new Map();
519
+ for (const row of rowsB) {
520
+ const cells = childRefs(row);
521
+ totalCellsB += cells.length;
522
+ const changedCells = changedChildIndices(row, cells, changes);
523
+ if (changedCells.size > 0) {
524
+ perRowChangedCells.set(row.index, changedCells);
525
+ changedCellsB += changedCells.size;
526
+ }
527
+ }
528
+ let totalCellsA = 0;
529
+ for (const row of rowsA) {
530
+ totalCellsA += row.node.childCount;
531
+ }
532
+ const cellDenom = Math.max(totalCellsA, totalCellsB, 1);
533
+
534
+ // Whole-table replacement.
535
+ if (changedCellsB / cellDenom >= thresholds.node.ratio) {
536
+ return [wholeBlockChange(blockA, blockB, changes)];
537
+ }
538
+ const out = [];
539
+ // LCS-align rows (mirrors classifyContainer) so an inserted/deleted row does not mis-pair
540
+ // every subsequent row by index (which would corrupt the row-level A coordinates).
541
+ const rowAlignment = alignChildren(rowsA, rowsB);
542
+ let lastMatchedRowAEnd = blockA.from + 1;
543
+ let lastMatchedRowBEnd = blockB.from + 1;
544
+ for (const {
545
+ a: rowA,
546
+ b: rowB
547
+ } of rowAlignment) {
548
+ // Added row: pure insertion (only if it actually carries changes).
549
+ if (rowB && !rowA) {
550
+ var _perRowChangedCells$g, _perRowChangedCells$g2;
551
+ 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) {
552
+ out.push(makePromotedChange(lastMatchedRowAEnd, lastMatchedRowAEnd, rowB.from, rowB.to, 'node'));
553
+ }
554
+ lastMatchedRowBEnd = rowB.to;
555
+ continue;
556
+ }
557
+ // Removed row: pure deletion.
558
+ if (rowA && !rowB) {
559
+ out.push(makePromotedChange(rowA.from, rowA.to, lastMatchedRowBEnd, lastMatchedRowBEnd, 'node'));
560
+ lastMatchedRowAEnd = rowA.to;
561
+ continue;
562
+ }
563
+ if (!rowA || !rowB) {
564
+ continue;
565
+ }
566
+ lastMatchedRowAEnd = rowA.to;
567
+ lastMatchedRowBEnd = rowB.to;
568
+ const changedCells = perRowChangedCells.get(rowB.index);
569
+ if (!changedCells || changedCells.size === 0) {
570
+ continue;
571
+ }
572
+ const cellsB = childRefs(rowB);
573
+ // Row-level replacement.
574
+ if (changedCells.size / Math.max(rowA.node.childCount, cellsB.length, 1) >= thresholds.node.ratio) {
575
+ out.push(makePromotedChange(rowA.from, rowA.to, rowB.from, rowB.to, 'node'));
576
+ continue;
577
+ }
578
+ // Else recurse into each changed cell's content (cell is never replaced alone). Cells are
579
+ // positionally aligned within a matched row (table columns are fixed, so a cell at index i
580
+ // on the B side corresponds to index i on the A side).
581
+ const cellsA = childRefs(rowA);
582
+ for (const cellB of cellsB) {
583
+ var _cellsA$cellB$index;
584
+ if (!changedCells.has(cellB.index)) {
585
+ continue;
586
+ }
587
+ const cellA = (_cellsA$cellB$index = cellsA[cellB.index]) !== null && _cellsA$cellB$index !== void 0 ? _cellsA$cellB$index : null;
588
+ const cellChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), cellB.from, cellB.to));
589
+ out.push(...classifyChild(cellA, cellB, cellChanges, originalDoc, newDoc, locale, thresholds));
590
+ }
591
+ }
592
+ return out;
593
+ };
594
+
595
+ /**
596
+ * Recurse into a rigid/structural child (listItem, layoutColumn, tableCell/tableHeader) or a
597
+ * plain child block. Rigid children are containers of blocks: we recurse into THEIR children
598
+ * (paragraphs, nested lists, …) so we never delete+replace the rigid child itself. A plain
599
+ * text-bearing or container child is classified directly.
600
+ */
601
+ const classifyChild = (childA, childB, changes, originalDoc, newDoc, locale, thresholds) => {
602
+ if (changes.length === 0) {
603
+ return [];
604
+ }
605
+ const typeName = childB.node.type.name;
606
+ const asBlock = ref => ref ? {
607
+ node: ref.node,
608
+ from: ref.from,
609
+ to: ref.to
610
+ } : null;
611
+
612
+ // Structurally-rigid wrapper (layoutColumn / tableCell / tableHeader): the wrapper itself is
613
+ // NEVER a whole-block result — deleting+re-inserting a single column or cell would break the
614
+ // parent layout/table structure. We only reach here because the parent already decided NOT
615
+ // to promote wholesale, so we bypass `classifyContainer` (which could promote the whole
616
+ // wrapper when it is internally dense) and classify EACH of the wrapper's direct children on
617
+ // its own. This keeps the diff strictly inside the wrapper (inline / sentence / paragraph,
618
+ // or a nested container such as a list inside a table cell).
619
+ if (RIGID_CHILD_TYPES.has(typeName)) {
620
+ const blockB = asBlock(childB);
621
+ if (!blockB) {
622
+ return [];
623
+ }
624
+ const wrapperA = asBlock(childA);
625
+ const childrenB = childRefs(blockB);
626
+ const childrenA = wrapperA ? childRefs(wrapperA) : [];
627
+ const out = [];
628
+ // LCS-align inner children (mirrors classifyContainer) so a paragraph inserted/deleted
629
+ // inside the cell/column does not mis-pair every subsequent inner child by index. We never
630
+ // promote the wrapper itself here — we only classify each inner child.
631
+ const alignment = alignChildren(childrenA, childrenB);
632
+ let lastMatchedAEnd = wrapperA ? wrapperA.from + 1 : blockB.from + 1;
633
+ let lastMatchedBEnd = blockB.from + 1;
634
+ for (const {
635
+ a,
636
+ b
637
+ } of alignment) {
638
+ if (a && b) {
639
+ lastMatchedAEnd = a.to;
640
+ lastMatchedBEnd = b.to;
641
+ const innerChanges = changes.filter(c => rangesOverlap(c.fromB, Math.max(c.toB, c.fromB + 1), b.from, b.to));
642
+ if (innerChanges.length === 0) {
643
+ continue;
644
+ }
645
+ out.push(...classifyChild(a, b, innerChanges, originalDoc, newDoc, locale, thresholds));
646
+ continue;
647
+ }
648
+ if (b && !a) {
649
+ // Added inner child: pure insertion, zero-width A anchor at the last matched A end.
650
+ out.push(makePromotedChange(lastMatchedAEnd, lastMatchedAEnd, b.from, b.to, 'node'));
651
+ lastMatchedBEnd = b.to;
652
+ continue;
653
+ }
654
+ if (a && !b) {
655
+ // Removed inner child: pure deletion, zero-width B anchor at the last matched B end.
656
+ out.push(makePromotedChange(a.from, a.to, lastMatchedBEnd, lastMatchedBEnd, 'node'));
657
+ lastMatchedAEnd = a.to;
658
+ }
659
+ }
660
+ return out;
661
+ }
662
+
663
+ // A `listItem` CAN be replaced wholesale (delete + re-insert at the same position does not
664
+ // break the list's structure), so per the spec it uses the normal container rule: promote
665
+ // the whole item when its own content is dense enough, else recurse into its children.
666
+ if (typeName === 'listItem') {
667
+ const blockB = asBlock(childB);
668
+ const blockAItem = asBlock(childA);
669
+ // Pure deletion of the item: emit the whole A-side item as deleted (zero-width B anchor at
670
+ // the item's own B-less position — anchored at the A start for lack of parent context).
671
+ if (!blockB) {
672
+ return blockAItem ? [makePromotedChange(blockAItem.from, blockAItem.to, blockAItem.from, blockAItem.from, 'node')] : [];
673
+ }
674
+ // Pure insertion of the item: emit the whole B-side item as inserted with a ZERO-WIDTH A
675
+ // anchor. (Using `blockB` as the A side would make the LCS compare the item against itself
676
+ // and drop the insertion.)
677
+ if (!blockAItem) {
678
+ return [makePromotedChange(blockB.from, blockB.from, blockB.from, blockB.to, 'node')];
679
+ }
680
+ return classifyContainer(blockAItem, blockB, changes, originalDoc, newDoc, locale, thresholds);
681
+ }
682
+
683
+ // Plain child block: classify as its own group (text or nested container).
684
+ return classifyBlockGroup({
685
+ blockA: asBlock(childA),
686
+ blockB: asBlock(childB),
687
+ changes
688
+ }, originalDoc, newDoc, locale, thresholds);
689
+ };