Skip to content

Commit ca91225

Browse files
authored
fix(diffing): ignore volatile OOXML attrs in image and paragraph diff comparison (#2421)
1 parent ab30a36 commit ca91225

8 files changed

Lines changed: 978 additions & 15 deletions

File tree

packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.test.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ const buildInlineNodeToken = (attrs = {}, type = { name: 'link' }, pos = 0) => {
6464
};
6565
};
6666

67+
/**
68+
* Builds a mock image inline-node token for diff tests.
69+
*
70+
* @param {Record<string, unknown>} attrs Image node attributes.
71+
* @param {number} pos Position offset for the image node.
72+
* @returns {import('./inline-diffing.ts').InlineNodeToken}
73+
*/
74+
const buildImageNodeToken = (attrs = {}, pos = 0) => {
75+
const nodeAttrs = { ...attrs };
76+
const type = { name: 'image' };
77+
return {
78+
kind: 'inlineNode',
79+
nodeType: 'image',
80+
node: {
81+
type,
82+
attrs: nodeAttrs,
83+
toJSON: () => ({ type: 'image', attrs: nodeAttrs }),
84+
},
85+
nodeJSON: { type: 'image', attrs: nodeAttrs },
86+
pos,
87+
};
88+
};
89+
6790
/**
6891
* Builds text tokens without offsets for tokenizer assertions.
6992
*
@@ -426,3 +449,131 @@ describe('tokenizeInlineContent', () => {
426449
expect(tokens[5]?.offset).toBe(16);
427450
});
428451
});
452+
453+
describe('image semantic normalization in inline diff', () => {
454+
it('produces no diff when images differ only in volatile originalAttributes', () => {
455+
const baseAttrs = {
456+
src: 'image1.png',
457+
size: { width: 100, height: 50 },
458+
originalAttributes: {
459+
'wp14:anchorId': 'AAAA1111',
460+
'wp14:editId': 'BBBB2222',
461+
cx: '914400',
462+
},
463+
};
464+
const changedAttrs = {
465+
src: 'image1.png',
466+
size: { width: 100, height: 50 },
467+
originalAttributes: {
468+
'wp14:anchorId': 'CCCC3333',
469+
'wp14:editId': 'DDDD4444',
470+
cx: '914400',
471+
},
472+
};
473+
474+
const oldToken = buildImageNodeToken(baseAttrs, 5);
475+
const newToken = buildImageNodeToken(changedAttrs, 5);
476+
477+
const diffs = getInlineDiff([oldToken], [newToken], 6);
478+
expect(diffs).toEqual([]);
479+
});
480+
481+
it('detects a real image change even when volatile attrs also differ', () => {
482+
const oldAttrs = {
483+
src: 'old-image.png',
484+
originalAttributes: { 'wp14:anchorId': 'A1', cx: '100' },
485+
};
486+
const newAttrs = {
487+
src: 'new-image.png',
488+
originalAttributes: { 'wp14:anchorId': 'A2', cx: '100' },
489+
};
490+
491+
const oldToken = buildImageNodeToken(oldAttrs, 3);
492+
const newToken = buildImageNodeToken(newAttrs, 3);
493+
494+
const diffs = getInlineDiff([oldToken], [newToken], 4);
495+
496+
expect(diffs).toHaveLength(1);
497+
expect(diffs[0].action).toBe('modified');
498+
expect(diffs[0].kind).toBe('inlineNode');
499+
expect(diffs[0].attrsDiff?.modified).toHaveProperty('src');
500+
});
501+
502+
it('handles multiple images in one paragraph using type-based pairing', () => {
503+
const mkImage = (src, anchorId, pos) =>
504+
buildImageNodeToken({ src, originalAttributes: { 'wp14:anchorId': anchorId, cx: '100' } }, pos);
505+
506+
const oldTokens = [mkImage('a.png', 'ID1', 1), mkImage('b.png', 'ID2', 3)];
507+
const newTokens = [mkImage('a.png', 'ID3', 1), mkImage('b.png', 'ID4', 3)];
508+
509+
const diffs = getInlineDiff(oldTokens, newTokens, 5);
510+
expect(diffs).toEqual([]);
511+
});
512+
513+
it('emits a diff when one of multiple images genuinely changes', () => {
514+
const mkImage = (src, anchorId, pos) =>
515+
buildImageNodeToken({ src, originalAttributes: { 'wp14:anchorId': anchorId } }, pos);
516+
517+
const oldTokens = [mkImage('a.png', 'ID1', 1), mkImage('b.png', 'ID2', 3)];
518+
const newTokens = [mkImage('a.png', 'ID3', 1), mkImage('c.png', 'ID4', 3)];
519+
520+
const diffs = getInlineDiff(oldTokens, newTokens, 5);
521+
522+
expect(diffs).toHaveLength(1);
523+
expect(diffs[0].action).toBe('modified');
524+
expect(diffs[0].attrsDiff?.modified).toHaveProperty('src');
525+
});
526+
527+
it('correctly detects an image insertion when a new image is prepended', () => {
528+
const mkImage = (src, pos) => buildImageNodeToken({ src }, pos);
529+
530+
const oldTokens = [mkImage('a.png', 1), mkImage('b.png', 3)];
531+
const newTokens = [mkImage('x.png', 1), mkImage('a.png', 3), mkImage('b.png', 5)];
532+
533+
const diffs = getInlineDiff(oldTokens, newTokens, 5);
534+
535+
// Should be a single insertion of x.png, not two modifications + addition
536+
expect(diffs).toHaveLength(1);
537+
expect(diffs[0].action).toBe('added');
538+
expect(diffs[0].kind).toBe('inlineNode');
539+
expect(diffs[0].nodeJSON.attrs.src).toBe('x.png');
540+
});
541+
542+
it('correctly detects image reordering as delete + add', () => {
543+
const mkImage = (src, pos) => buildImageNodeToken({ src }, pos);
544+
545+
const oldTokens = [mkImage('a.png', 1), mkImage('b.png', 3)];
546+
const newTokens = [mkImage('b.png', 1), mkImage('a.png', 3)];
547+
548+
const diffs = getInlineDiff(oldTokens, newTokens, 5);
549+
550+
// Reorder produces diffs — at minimum some combination of added/deleted
551+
expect(diffs.length).toBeGreaterThan(0);
552+
});
553+
554+
it('excludes volatile attrs from attrsDiff when a real image change occurs', () => {
555+
const oldAttrs = {
556+
src: 'v1.png',
557+
size: { width: 100 },
558+
originalAttributes: { 'wp14:anchorId': 'OLD', 'wp14:editId': 'OLD', cx: '100' },
559+
};
560+
const newAttrs = {
561+
src: 'v2.png',
562+
size: { width: 200 },
563+
originalAttributes: { 'wp14:anchorId': 'NEW', 'wp14:editId': 'NEW', cx: '100' },
564+
};
565+
566+
const diffs = getInlineDiff([buildImageNodeToken(oldAttrs, 1)], [buildImageNodeToken(newAttrs, 1)], 2);
567+
568+
expect(diffs).toHaveLength(1);
569+
const attrsDiff = diffs[0].attrsDiff;
570+
571+
// Semantic changes are reported
572+
expect(attrsDiff?.modified).toHaveProperty('src');
573+
expect(attrsDiff?.modified).toHaveProperty('size.width');
574+
575+
// Volatile changes are NOT reported
576+
expect(attrsDiff?.modified).not.toHaveProperty('originalAttributes.wp14:anchorId');
577+
expect(attrsDiff?.modified).not.toHaveProperty('originalAttributes.wp14:editId');
578+
});
579+
});

packages/super-editor/src/extensions/diffing/algorithm/inline-diffing.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Node as PMNode } from 'prosemirror-model';
22
import { getAttributesDiff, getMarksDiff, type AttributesDiff, type MarksDiff } from './attributes-diffing';
3+
import { normalizeInlineNodeJSON, normalizeInlineNodeAttrs, semanticInlineNodeKey } from './semantic-normalization';
34
import { diffSequences } from './sequence-diffing';
45

56
type NodeJSON = ReturnType<PMNode['toJSON']>;
@@ -237,7 +238,9 @@ export function getInlineDiff(
237238
buildDeleted: (token, oldIdx) => buildInlineDiff('deleted', token, oldIdx),
238239
buildModified: (oldToken, newToken, oldIdx) => {
239240
if (oldToken.kind !== 'text' && newToken.kind !== 'text') {
240-
const attrsDiff = getAttributesDiff(oldToken.node.attrs, newToken.node.attrs);
241+
const oldNormalized = normalizeInlineNodeAttrs(oldToken.node.type.name, oldToken.node.attrs);
242+
const newNormalized = normalizeInlineNodeAttrs(newToken.node.type.name, newToken.node.attrs);
243+
const attrsDiff = getAttributesDiff(oldNormalized, newNormalized);
241244
return {
242245
action: 'modified',
243246
idx: oldIdx,
@@ -270,7 +273,8 @@ export function getInlineDiff(
270273

271274
/**
272275
* Compares two inline tokens to decide if they can be considered equal for the Myers diff.
273-
* Text tokens compare character equality while inline nodes compare their type.
276+
* Text tokens compare character equality. Inline nodes compare by semantic identity
277+
* (normalized JSON), not just type name, so that distinct images are not falsely paired.
274278
*/
275279
function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean {
276280
if (a.kind !== b.kind) {
@@ -281,7 +285,7 @@ function inlineComparator(a: InlineDiffToken, b: InlineDiffToken): boolean {
281285
return a.char === b.char;
282286
}
283287
if (a.kind === 'inlineNode' && b.kind === 'inlineNode') {
284-
return a.node.type.name === b.node.type.name;
288+
return semanticInlineNodeKey(a.node) === semanticInlineNodeKey(b.node);
285289
}
286290
return false;
287291
}
@@ -299,8 +303,8 @@ function shouldProcessEqualAsModification(oldToken: InlineDiffToken, newToken: I
299303
}
300304

301305
if (oldToken.kind === 'inlineNode' && newToken.kind === 'inlineNode') {
302-
const oldJSON = oldToken.node.toJSON();
303-
const newJSON = newToken.node.toJSON();
306+
const oldJSON = normalizeInlineNodeJSON(oldToken.node.toJSON());
307+
const newJSON = normalizeInlineNodeJSON(newToken.node.toJSON());
304308
return JSON.stringify(oldJSON) !== JSON.stringify(newJSON);
305309
}
306310

0 commit comments

Comments
 (0)