@atlaskit/editor-plugin-show-diff 10.1.22 → 10.2.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.
- package/CHANGELOG.md +43 -0
- package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
- package/dist/cjs/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -2
- package/dist/cjs/pm-plugins/calculateDiff/smart/classifySmartChanges.js +841 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/helpers.js +135 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/segmentText.js +331 -0
- package/dist/cjs/pm-plugins/calculateDiff/smart/thresholds.js +46 -0
- package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/cjs/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/cjs/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
- package/dist/cjs/pm-plugins/decorations/decorationKeys.js +8 -5
- package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
- package/dist/cjs/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/cjs/pm-plugins/isExtendedEnabled.js +20 -0
- package/dist/cjs/pm-plugins/main.js +21 -16
- package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +119 -29
- package/dist/es2019/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
- package/dist/es2019/pm-plugins/calculateDiff/smart/classifySmartChanges.js +689 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/helpers.js +106 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/segmentText.js +283 -0
- package/dist/es2019/pm-plugins/calculateDiff/smart/thresholds.js +45 -0
- package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/es2019/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/es2019/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +31 -16
- package/dist/es2019/pm-plugins/decorations/decorationKeys.js +9 -5
- package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -34
- package/dist/es2019/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/es2019/pm-plugins/isExtendedEnabled.js +12 -0
- package/dist/es2019/pm-plugins/main.js +21 -16
- package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +132 -31
- package/dist/esm/pm-plugins/calculateDiff/groupChangesByBlock.js +4 -3
- package/dist/esm/pm-plugins/calculateDiff/smart/classifySmartChanges.js +834 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/helpers.js +128 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/segmentText.js +325 -0
- package/dist/esm/pm-plugins/calculateDiff/smart/thresholds.js +39 -0
- package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +24 -18
- package/dist/esm/pm-plugins/decorations/createChangedRowDecorationWidgets.js +18 -12
- package/dist/esm/pm-plugins/decorations/createInlineChangedDecoration.js +6 -5
- package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +30 -17
- package/dist/esm/pm-plugins/decorations/decorationKeys.js +9 -5
- package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +52 -30
- package/dist/esm/pm-plugins/getScrollableDecorations.js +6 -6
- package/dist/esm/pm-plugins/isExtendedEnabled.js +14 -0
- package/dist/esm/pm-plugins/main.js +21 -16
- package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
- package/dist/types/pm-plugins/calculateDiff/calculateDiffDecorations.d.ts +3 -1
- package/dist/types/pm-plugins/calculateDiff/groupChangesByBlock.d.ts +9 -0
- package/dist/types/pm-plugins/calculateDiff/smart/classifySmartChanges.d.ts +26 -0
- package/dist/types/pm-plugins/calculateDiff/smart/helpers.d.ts +44 -0
- package/dist/types/pm-plugins/calculateDiff/smart/segmentText.d.ts +32 -0
- package/dist/types/pm-plugins/calculateDiff/smart/thresholds.d.ts +44 -0
- package/dist/types/pm-plugins/decorations/createBlockChangedDecoration.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createChangedRowDecorationWidgets.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createInlineChangedDecoration.d.ts +3 -2
- package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +4 -2
- package/dist/types/pm-plugins/decorations/decorationKeys.d.ts +5 -3
- package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +6 -4
- package/dist/types/pm-plugins/getScrollableDecorations.d.ts +2 -1
- package/dist/types/pm-plugins/isExtendedEnabled.d.ts +11 -0
- package/dist/types/pm-plugins/main.d.ts +11 -1
- package/dist/types/showDiffPluginType.d.ts +20 -1
- package/docs/smart-diff-design.md +240 -0
- 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
|
+
};
|