@atlaskit/editor-plugin-show-diff 12.1.2 → 12.1.4

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 (36) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/compass.yml +3 -3
  3. package/dist/cjs/pm-plugins/calculateDiff/attrAwareTokenEncoder.js +76 -0
  4. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +47 -124
  5. package/dist/cjs/pm-plugins/calculateDiff/computeDiffChanges.js +8 -1
  6. package/dist/cjs/pm-plugins/calculateDiff/diffBySteps.js +3 -125
  7. package/dist/cjs/pm-plugins/calculateDiff/smart/classifySmartChanges.js +9 -0
  8. package/dist/cjs/pm-plugins/decorations/colorSchemes/factory.js +678 -0
  9. package/dist/cjs/pm-plugins/decorations/colorSchemes/schemes.js +39 -0
  10. package/dist/cjs/pm-plugins/decorations/colorSchemes/types.js +1 -0
  11. package/dist/es2019/pm-plugins/calculateDiff/attrAwareTokenEncoder.js +64 -0
  12. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +15 -89
  13. package/dist/es2019/pm-plugins/calculateDiff/computeDiffChanges.js +8 -1
  14. package/dist/es2019/pm-plugins/calculateDiff/diffBySteps.js +2 -105
  15. package/dist/es2019/pm-plugins/calculateDiff/smart/classifySmartChanges.js +9 -0
  16. package/dist/es2019/pm-plugins/decorations/colorSchemes/factory.js +625 -0
  17. package/dist/es2019/pm-plugins/decorations/colorSchemes/schemes.js +33 -0
  18. package/dist/es2019/pm-plugins/decorations/colorSchemes/types.js +0 -0
  19. package/dist/esm/pm-plugins/calculateDiff/attrAwareTokenEncoder.js +70 -0
  20. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +48 -125
  21. package/dist/esm/pm-plugins/calculateDiff/computeDiffChanges.js +8 -1
  22. package/dist/esm/pm-plugins/calculateDiff/diffBySteps.js +2 -124
  23. package/dist/esm/pm-plugins/calculateDiff/smart/classifySmartChanges.js +9 -0
  24. package/dist/esm/pm-plugins/decorations/colorSchemes/factory.js +622 -0
  25. package/dist/esm/pm-plugins/decorations/colorSchemes/schemes.js +33 -0
  26. package/dist/esm/pm-plugins/decorations/colorSchemes/types.js +0 -0
  27. package/dist/types/pm-plugins/calculateDiff/attrAwareTokenEncoder.d.ts +7 -0
  28. package/dist/types/pm-plugins/calculateDiff/diffBySteps.d.ts +0 -20
  29. package/dist/types/pm-plugins/decorations/colorSchemes/factory.d.ts +101 -0
  30. package/dist/types/pm-plugins/decorations/colorSchemes/schemes.d.ts +5 -0
  31. package/dist/types/pm-plugins/decorations/colorSchemes/types.d.ts +38 -0
  32. package/package.json +6 -6
  33. package/dist/cjs/pm-plugins/decorations/createGranularBlockReferenceWidget.js +0 -125
  34. package/dist/es2019/pm-plugins/decorations/createGranularBlockReferenceWidget.js +0 -120
  35. package/dist/esm/pm-plugins/decorations/createGranularBlockReferenceWidget.js +0 -120
  36. package/dist/types/pm-plugins/decorations/createGranularBlockReferenceWidget.d.ts +0 -36
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.traditionalScheme = exports.standardScheme = void 0;
7
+ /** Green insertions, red deletions. */
8
+ var traditionalScheme = exports.traditionalScheme = {
9
+ insertColor: 'green',
10
+ insertUnderlineStyle: 'solid',
11
+ insertActiveColor: 'green',
12
+ deleteColor: 'red',
13
+ deleteActiveColor: 'red',
14
+ deletedCellColor: 'gray',
15
+ insertedCellOpacity: 0.5,
16
+ deletedQuoteNodeShape: 'ring',
17
+ deletedCellBorderProminence: 'subtle',
18
+ deletedRowTreatment: 'strikeColor',
19
+ deletedBlockOutlineActiveEmphasis: 'background',
20
+ addedCellOverlayZIndex: 1,
21
+ roundedAddedCellOverlayInheritsBorder: true
22
+ };
23
+
24
+ /** Purple insertions, gray deletions (red on active). */
25
+ var standardScheme = exports.standardScheme = {
26
+ insertColor: 'purple',
27
+ insertUnderlineStyle: 'dotted',
28
+ insertActiveColor: 'purple',
29
+ deleteColor: 'gray',
30
+ deleteActiveColor: 'red',
31
+ deletedCellColor: 'gray',
32
+ insertedCellOpacity: 0.2,
33
+ deletedQuoteNodeShape: 'borderLeft',
34
+ deletedCellBorderProminence: 'accent',
35
+ deletedRowTreatment: 'textTint',
36
+ deletedBlockOutlineActiveEmphasis: 'border',
37
+ addedCellOverlayZIndex: 2,
38
+ roundedAddedCellOverlayInheritsBorder: false
39
+ };
@@ -0,0 +1 @@
1
+ "use strict";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Attribute-aware token encoder for `prosemirror-changeset`.
3
+ *
4
+ * The library's default encoder reduces a node's open token to `node.type.name`,
5
+ * ignoring attributes. An attribute-only change (e.g. recolouring a table cell's
6
+ * `background`) therefore tokenises identically on both sides and reports no
7
+ * change at all.
8
+ *
9
+ * This encoder folds an allow-listed set of attributes into the open token so
10
+ * such changes register. Everything else encodes exactly as the default. The
11
+ * allow-list is deliberately narrow — folding in ephemeral attrs like `localId`
12
+ * would produce phantom diffs.
13
+ */
14
+
15
+ const DIFFED_ATTRS_BY_NODE_TYPE = {
16
+ tableCell: ['background', 'colspan', 'rowspan'],
17
+ tableHeader: ['background', 'colspan', 'rowspan']
18
+ };
19
+
20
+ /**
21
+ * Builds a stable composite token. Iterates the allow-list rather than
22
+ * `Object.keys` so ordering is deterministic for string comparison.
23
+ */
24
+ const encodeNodeWithAttrs = (node, attrNames) => {
25
+ var _node$attrs;
26
+ const attrs = (_node$attrs = node.attrs) !== null && _node$attrs !== void 0 ? _node$attrs : {};
27
+ // Deterministic order: iterate the allow-list, not `Object.keys(attrs)`.
28
+ const parts = attrNames.map(name => {
29
+ var _attrs$name;
30
+ return `${name}=${JSON.stringify((_attrs$name = attrs[name]) !== null && _attrs$name !== void 0 ? _attrs$name : null)}`;
31
+ });
32
+ return `${node.type.name}|${parts.join('|')}`;
33
+ };
34
+
35
+ /**
36
+ * Identical to the library default except that allow-listed node types encode
37
+ * their allow-listed attributes into the open token. Characters and node-end
38
+ * tokens are left as-is, hence the `string | number` token type.
39
+ */
40
+ export const attrAwareTokenEncoder = {
41
+ encodeCharacter: (char, _marks) => char,
42
+ encodeNodeStart: node => {
43
+ const attrNames = DIFFED_ATTRS_BY_NODE_TYPE[node.type.name];
44
+ if (attrNames) {
45
+ return encodeNodeWithAttrs(node, attrNames);
46
+ }
47
+ return node.type.name;
48
+ },
49
+ encodeNodeEnd: node => -typeID(node.type),
50
+ compareTokens: (a, b) => a === b
51
+ };
52
+
53
+ /**
54
+ * Mirrors the library's private `typeID` so node-end tokens match the default
55
+ * encoding exactly. Reimplemented here because it is not exported.
56
+ */
57
+ function typeID(type) {
58
+ const cache = type.schema.cached.changeSetIDs || (type.schema.cached.changeSetIDs = Object.create(null));
59
+ let id = cache[type.name];
60
+ if (id == null) {
61
+ cache[type.name] = id = Object.keys(type.schema.nodes).indexOf(type.name) + 1;
62
+ }
63
+ return id;
64
+ }
@@ -10,14 +10,14 @@ import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
10
10
  import { areDocsEqualByBlockStructureAndText } from '../areDocsEqualByBlockStructureAndText';
11
11
  import { createDocMarginAnchorWidget } from '../decorations/createAnchorDecorationWidgets';
12
12
  import { createBlockChangedDecoration } from '../decorations/createBlockChangedDecoration';
13
- import { createGranularBlockReferenceWidget } from '../decorations/createGranularBlockReferenceWidget';
14
13
  import { createInlineChangedDecoration } from '../decorations/createInlineChangedDecoration';
15
14
  import { createNodeChangedDecorationWidget } from '../decorations/createNodeChangedDecorationWidget';
16
15
  import { extractDiffDescriptors } from '../decorations/decorationKeys';
17
16
  import { getAttrChangeRanges, stepIsValidAttrChange } from '../decorations/utils/getAttrChangeRanges';
18
17
  import { getMarkChangeRanges } from '../decorations/utils/getMarkChangeRanges';
19
18
  import { isExtendedEnabled } from '../isExtendedEnabled';
20
- import { diffBySteps, getStepChanges } from './diffBySteps';
19
+ import { attrAwareTokenEncoder } from './attrAwareTokenEncoder';
20
+ import { diffBySteps } from './diffBySteps';
21
21
  import { groupChangesByBlock } from './groupChangesByBlock';
22
22
  import { optimizeChanges } from './optimizeChanges';
23
23
  import { simplifySteps } from './simplifySteps';
@@ -213,7 +213,11 @@ const calculateDiffDecorationsInner = ({
213
213
  };
214
214
  }
215
215
  }
216
- const changeset = ChangeSet.create(originalDoc).addSteps(steppedDoc, stepMaps, tr.doc);
216
+ // The attribute-aware encoder is only needed by the smart classifier and is
217
+ // gated with it; other diff types keep the library default so their output is
218
+ // unchanged.
219
+ const tokenEncoder = diffType === 'smart' && fg('platform_editor_ai_smart_diff') ? attrAwareTokenEncoder : undefined;
220
+ const changeset = ChangeSet.create(originalDoc, undefined, tokenEncoder).addSteps(steppedDoc, stepMaps, tr.doc);
217
221
  const changes = getChanges({
218
222
  changeset,
219
223
  originalDoc,
@@ -235,7 +239,7 @@ const calculateDiffDecorationsInner = ({
235
239
 
236
240
  // Our default operations are insertions, so it should match the opposite of isInverted.
237
241
  const isInserted = !isInverted;
238
- const createDecorationsForChange = (change, showGranularWithBlock) => {
242
+ const createDecorationsForChange = change => {
239
243
  const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
240
244
 
241
245
  // Hoisted because it decides BOTH where the deleted widget is anchored and — since the
@@ -247,10 +251,8 @@ const calculateDiffDecorationsInner = ({
247
251
  inlineDeletedDiffPlacement
248
252
  });
249
253
  if (change.inserted.length > 0) {
250
- // shouldHideDeleted for block/node decorations: suppressed when isInverted + hideDeletedDiffs,
251
- // or when showGranularWithBlock (block reference widget is shown instead).
252
- // isInverted gates both — on an inverted diff the inserted side is visually the deleted side.
253
- const shouldHideDeleted = isExtendedEnabled(diffType) ? isInverted && (hideDeletedDiffs || showGranularWithBlock && change.deleted.length > 0) : false;
254
+ // On an inverted diff the inserted side is visually the deleted side.
255
+ const shouldHideDeleted = isExtendedEnabled(diffType) ? isInverted && hideDeletedDiffs : false;
254
256
 
255
257
  // For `smart` NODE-level promotions the change range spans a whole container
256
258
  // (e.g. an entire list/table/layout, using outer node bounds). Applying a SINGLE
@@ -267,7 +269,7 @@ const calculateDiffDecorationsInner = ({
267
269
  // change. Used to decide if indicator anchor positions should be adjusted
268
270
  // inward — when the widget is present the anchor must stay at the block
269
271
  // boundary to keep the indicator bar continuous with the deleted content.
270
- const willRenderDeletedWidget = change.deleted.length > 0 && !(isExtendedEnabled(diffType) && !isInverted && (hideDeletedDiffs || showGranularWithBlock) && change.inserted.length > 0);
272
+ const willRenderDeletedWidget = change.deleted.length > 0 && !(isExtendedEnabled(diffType) && !isInverted && hideDeletedDiffs && change.inserted.length > 0);
271
273
  if (isSmartNodeLevel) {
272
274
  for (const [from, to] of leafTextblockRanges(tr.doc, change.fromB, change.toB)) {
273
275
  decorations.push(...createInlineChangedDecoration({
@@ -322,7 +324,7 @@ const calculateDiffDecorationsInner = ({
322
324
  }));
323
325
  }
324
326
  if (change.deleted.length > 0) {
325
- const shouldHideDeleted = isExtendedEnabled(diffType) ? !isInverted && (hideDeletedDiffs || showGranularWithBlock) && change.inserted.length > 0 : false;
327
+ const shouldHideDeleted = isExtendedEnabled(diffType) ? !isInverted && hideDeletedDiffs && change.inserted.length > 0 : false;
326
328
  if (!shouldHideDeleted) {
327
329
  decorations.push(...createNodeChangedDecorationWidget({
328
330
  change,
@@ -343,85 +345,9 @@ const calculateDiffDecorationsInner = ({
343
345
  }
344
346
  }
345
347
  };
346
- if (diffType === 'step' && expValEquals('platform_editor_diff_granular_extended', 'isEnabled', true)) {
347
- // Uses getStepChanges instead of getChanges so that we have per-step granularity metadata.
348
- // Specifically, we need to know how many granular changes each step produced in order to
349
- // apply the shouldHideDeleted suppression threshold (> 3 granular changes per step).
350
- // getChanges returns a flat Change[] with no per-step grouping, making this count impossible
351
- // to derive after the fact without re-introducing per-change metadata.
352
- const stepChanges = getStepChanges(originalDoc, steps);
353
- stepChanges.forEach(({
354
- isGranular,
355
- changes: stepChangeList
356
- }) => {
357
- const granularCount = isGranular ? stepChangeList.length : 0;
358
-
359
- // Calculate the average ratio of changed content on both A (original) and B (new)
360
- // sides of the diff. If 30% or more of the block has changed on average, we show
361
- // the block reference widget even if the granular change count is below the threshold.
362
- // Block length is derived from the enclosing text block boundaries rather than the
363
- // first/last change positions, so unchanged words at the start/end are accounted for.
364
- let avgChangedRatio = 0;
365
- if (isGranular && stepChangeList.length > 0) {
366
- const first = stepChangeList[0];
367
- const last = stepChangeList[stepChangeList.length - 1];
368
- const resolvedA = originalDoc.resolve(first.fromA);
369
- const resolvedB = tr.doc.resolve(first.fromB);
370
- let blockStartA = first.fromA;
371
- let blockEndA = last.toA;
372
- let blockStartB = first.fromB;
373
- let blockEndB = last.toB;
374
- for (let depth = resolvedA.depth; depth >= 0; depth--) {
375
- const node = resolvedA.node(depth);
376
- if (node.isTextblock) {
377
- blockStartA = resolvedA.start(depth);
378
- blockEndA = blockStartA + node.nodeSize - 2; // exclude open/close tokens
379
- break;
380
- }
381
- }
382
- for (let depth = resolvedB.depth; depth >= 0; depth--) {
383
- const node = resolvedB.node(depth);
384
- if (node.isTextblock) {
385
- blockStartB = resolvedB.start(depth);
386
- blockEndB = blockStartB + node.nodeSize - 2; // exclude open/close tokens
387
- break;
388
- }
389
- }
390
- const blockLengthA = blockEndA - blockStartA;
391
- const blockLengthB = blockEndB - blockStartB;
392
- const totalChangedA = stepChangeList.reduce((sum, c) => sum + (c.toA - c.fromA), 0);
393
- const totalChangedB = stepChangeList.reduce((sum, c) => sum + (c.toB - c.fromB), 0);
394
- const ratioA = blockLengthA > 0 ? totalChangedA / blockLengthA : 0;
395
- const ratioB = blockLengthB > 0 ? totalChangedB / blockLengthB : 0;
396
- avgChangedRatio = (ratioA + ratioB) / 2;
397
- }
398
- const showGranularWithBlock = isGranular && granularCount !== 1 && (granularCount > 3 || avgChangedRatio >= 0.3);
399
- stepChangeList.forEach(change => {
400
- createDecorationsForChange(change, showGranularWithBlock);
401
- });
402
- if (showGranularWithBlock && stepChangeList.length > 0 && !hideDeletedDiffs) {
403
- const lastChange = stepChangeList[stepChangeList.length - 1];
404
- const granularBlockDiffId = crypto.randomUUID();
405
- const blockWidgets = createGranularBlockReferenceWidget({
406
- change: lastChange,
407
- originalDoc,
408
- newDoc: tr.doc,
409
- isInverted,
410
- nodeViewSerializer,
411
- colorScheme,
412
- intl,
413
- activeIndexPos,
414
- diffId: granularBlockDiffId,
415
- showIndicators
416
- });
417
- decorations.push(...blockWidgets);
418
- }
419
- });
420
- } else {
421
- changes.forEach(change => {
422
- createDecorationsForChange(change, /* showGranularWithBlock */false);
423
- });
424
- }
348
+ changes.forEach(change => {
349
+ createDecorationsForChange(change);
350
+ });
425
351
  getMarkChangeRanges(steps).forEach(change => {
426
352
  const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
427
353
  decorations.push(...createInlineChangedDecoration({
@@ -14,6 +14,7 @@
14
14
  * reconstructed by applying the (simplified) steps to `originalDoc`.
15
15
  */
16
16
  import { ChangeSet, simplifyChanges } from 'prosemirror-changeset';
17
+ import { attrAwareTokenEncoder } from './attrAwareTokenEncoder';
17
18
  import { diffBySteps } from './diffBySteps';
18
19
  import { groupChangesByBlock } from './groupChangesByBlock';
19
20
  import { optimizeChanges } from './optimizeChanges';
@@ -62,7 +63,13 @@ export const computeDiffChanges = ({
62
63
  newDoc: originalDoc
63
64
  };
64
65
  }
65
- const changeset = ChangeSet.create(originalDoc).addSteps(steppedDoc, stepMaps, steppedDoc);
66
+
67
+ // The attribute-aware encoder only affects the `smart` classification, so it is
68
+ // applied only for that type. (This utility intentionally applies no feature
69
+ // gate — see the file docstring — but a caller requesting `smart` is already
70
+ // behind the smart-diff gate.)
71
+ const tokenEncoder = diffType === 'smart' ? attrAwareTokenEncoder : undefined;
72
+ const changeset = ChangeSet.create(originalDoc, undefined, tokenEncoder).addSteps(steppedDoc, stepMaps, steppedDoc);
66
73
  if (diffType === 'smart') {
67
74
  return {
68
75
  changes: classifySmartChanges({
@@ -1,6 +1,7 @@
1
1
  import { simplifyChanges, ChangeSet } from 'prosemirror-changeset';
2
2
  import { Mark } from '@atlaskit/editor-prosemirror/model';
3
3
  import { Mapping, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
4
+ import { attrAwareTokenEncoder } from './attrAwareTokenEncoder';
4
5
  import { optimizeChanges } from './optimizeChanges';
5
6
 
6
7
  // @ts-ignore TS1501: This regular expression flag is only available when targeting 'es6' or later.
@@ -292,7 +293,7 @@ export const diffBySteps = (originalDoc, steps) => {
292
293
  const fromB = mapPosition(afterStepToFinal, fromAfterStep);
293
294
  const toB = mapPosition(afterStepToFinal, toAfterStep);
294
295
  if (shouldCheckGranularDiff(rangedStep.step, rangedStep.before, rangedStep.from, rangedStep.to)) {
295
- const granularStepChanges = ChangeSet.create(rangedStep.before).addSteps(rangedStep.doc, [rangedStep.stepMap], null);
296
+ const granularStepChanges = ChangeSet.create(rangedStep.before, undefined, attrAwareTokenEncoder).addSteps(rangedStep.doc, [rangedStep.stepMap], null);
296
297
 
297
298
  // `simplifyChanges` reads text using `Change.fromB`/`toB`, which are
298
299
  // positions in the post-step doc (the "B" doc). Passing the pre-step
@@ -362,108 +363,4 @@ export const diffBySteps = (originalDoc, steps) => {
362
363
  });
363
364
  }
364
365
  return mergeOverlappingByNewDocRange(changes);
365
- };
366
-
367
- /**
368
- * A fork of `diffBySteps` that returns changes grouped per step, rather than as a flat list.
369
- *
370
- * Why forked rather than refactoring `diffBySteps`:
371
- * - `diffBySteps` returns a flat `Change[]` and is consumed by the existing decoration path.
372
- * Changing its return shape would require threading per-step metadata through all callers,
373
- * adding complexity to a stable code path.
374
- * - The per-step grouping is only needed for the `platform_editor_diff_granular_extended` gate,
375
- * where we need to know how many granular changes a single step produced in order to decide
376
- * whether to suppress deleted decorations (threshold: > 3 granular changes per step).
377
- * - Keeping the two functions separate means each has a clear, focused contract and neither
378
- * accumulates the other's concerns. Shared logic (mapping helpers, `mergeOverlappingByNewDocRange`,
379
- * `shouldCheckGranularDiff`, etc.) is already extracted and reused by both.
380
- */
381
- export const getStepChanges = (originalDoc, steps) => {
382
- const result = [];
383
- let currentDoc = originalDoc;
384
- const successfulStepMaps = [];
385
- const rangedSteps = [];
386
- for (const step of steps) {
387
- const before = currentDoc;
388
- const stepResult = step.apply(currentDoc);
389
- if (stepResult.failed !== null || !stepResult.doc) {
390
- continue;
391
- }
392
- const stepMap = step.getMap();
393
- const rangeStep = step;
394
- if (typeof rangeStep.from === 'number' && typeof rangeStep.to === 'number') {
395
- rangedSteps.push({
396
- before,
397
- doc: stepResult.doc,
398
- from: rangeStep.from,
399
- to: rangeStep.to,
400
- mapIndex: successfulStepMaps.length,
401
- step,
402
- stepMap
403
- });
404
- }
405
- successfulStepMaps.push(stepMap);
406
- currentDoc = stepResult.doc;
407
- }
408
- for (const rangedStep of rangedSteps) {
409
- const originalToBeforeStep = createMapping(successfulStepMaps.slice(0, rangedStep.mapIndex));
410
- const beforeStepToOriginal = originalToBeforeStep.invert();
411
- const fromA = mapPosition(beforeStepToOriginal, rangedStep.from);
412
- const toA = mapPosition(beforeStepToOriginal, rangedStep.to);
413
- const fromAfterStep = rangedStep.stepMap.map(rangedStep.from, -1);
414
- const toAfterStep = rangedStep.stepMap.map(rangedStep.to, 1);
415
- const afterStepToFinal = createMapping(successfulStepMaps.slice(rangedStep.mapIndex + 1));
416
- const fromB = mapPosition(afterStepToFinal, fromAfterStep);
417
- const toB = mapPosition(afterStepToFinal, toAfterStep);
418
- if (shouldCheckGranularDiff(rangedStep.step, rangedStep.before, rangedStep.from, rangedStep.to)) {
419
- const granularStepChanges = ChangeSet.create(rangedStep.before).addSteps(rangedStep.doc, [rangedStep.stepMap], null);
420
- const optimizedGranularStepChanges = optimizeChanges(simplifyChanges(granularStepChanges.changes, rangedStep.doc));
421
- const stepChanges = [];
422
- for (const granularChange of optimizedGranularStepChanges) {
423
- const expandedA = expandToWordBoundaries(rangedStep.before, granularChange.fromA, granularChange.toA);
424
- const expandedB = expandToWordBoundaries(rangedStep.doc, granularChange.fromB, granularChange.toB);
425
- const aLeftDelta = granularChange.fromA - expandedA.from;
426
- const aRightDelta = expandedA.to - granularChange.toA;
427
- const bLeftDelta = granularChange.fromB - expandedB.from;
428
- const bRightDelta = expandedB.to - granularChange.toB;
429
- let finalA = expandedA;
430
- let finalB = expandedB;
431
- if (aLeftDelta > bLeftDelta || aRightDelta > bRightDelta) {
432
- const extraLeft = Math.max(0, aLeftDelta - bLeftDelta);
433
- const extraRight = Math.max(0, aRightDelta - bRightDelta);
434
- finalB = expandToWordBoundaries(rangedStep.doc, Math.max(expandedB.from - extraLeft, 0), expandedB.to + extraRight);
435
- }
436
- if (bLeftDelta > aLeftDelta || bRightDelta > aRightDelta) {
437
- const extraLeft = Math.max(0, bLeftDelta - aLeftDelta);
438
- const extraRight = Math.max(0, bRightDelta - aRightDelta);
439
- finalA = expandToWordBoundaries(rangedStep.before, Math.max(expandedA.from - extraLeft, 0), expandedA.to + extraRight);
440
- }
441
- stepChanges.push({
442
- fromA: mapPosition(beforeStepToOriginal, finalA.from),
443
- toA: mapPosition(beforeStepToOriginal, finalA.to),
444
- fromB: mapPosition(afterStepToFinal, finalB.from),
445
- toB: mapPosition(afterStepToFinal, finalB.to),
446
- deleted: createSpans(Math.max(0, finalA.to - finalA.from)),
447
- inserted: createSpans(Math.max(0, finalB.to - finalB.from))
448
- });
449
- }
450
- result.push({
451
- isGranular: true,
452
- changes: mergeOverlappingByNewDocRange(stepChanges)
453
- });
454
- continue;
455
- }
456
- result.push({
457
- isGranular: false,
458
- changes: [{
459
- fromA,
460
- toA,
461
- fromB,
462
- toB,
463
- deleted: createSpans(Math.max(0, toA - fromA)),
464
- inserted: createSpans(Math.max(0, toB - fromB))
465
- }]
466
- });
467
- }
468
- return result;
469
366
  };
@@ -635,6 +635,15 @@ const classifyChild = (childA, childB, changes, originalDoc, newDoc, locale, thr
635
635
  const childrenB = childRefs(blockB);
636
636
  const childrenA = wrapperA ? childRefs(wrapperA) : [];
637
637
  const out = [];
638
+
639
+ // An attribute-only change on the wrapper itself (e.g. a table cell's
640
+ // `background`) sits on the node boundary, not inside any inner child, so the
641
+ // recursion below would emit nothing and the change would be dropped. Emit a
642
+ // whole-wrapper change instead, which also subsumes any inner content change.
643
+ if (wrapperA && !wrapperA.node.sameMarkup(blockB.node)) {
644
+ out.push(makePromotedChange(wrapperA.from, wrapperA.to, blockB.from, blockB.to, 'node'));
645
+ return out;
646
+ }
638
647
  // LCS-align inner children (mirrors classifyContainer) so a paragraph inserted/deleted
639
648
  // inside the cell/column does not mis-pair every subsequent inner child by index. We never
640
649
  // promote the wrapper itself here — we only classify each inner child.