Skip to content

Commit 4b6f30e

Browse files
feat: add header and footer diffing support (SD-2238) (#2575)
* feat(diff): add header/footer diff capture and replay * feat(editor): refresh presentation after header/footer diff replay * fix(diff): restore v1 compatibility and header/footer replay state * fix(diff): gate header/footer capture on target coverage in compareToSnapshot * fix(diff): reuse normalizePartPath in replay to fix denormalized relationship targets findPartPathByRefId only prepended "word/" without stripping relative prefixes (./、../、/), producing keys like "word/./header1.xml" that don't match the canonical keys used during capture. Import and call the shared normalizePartPath instead. * fix(diff): only sync title page cache when slot changes were actually applied syncTitlePageCache was called whenever the diff contained slot changes, even if all of them were skipped (e.g. section projection not found). Track the count of successfully applied slot changes and gate the cache sync on that instead. * refactor(diff): deduplicate SLOT_VARIANTS constant across diffing modules Export SLOT_VARIANTS from header-footer-diffing.ts and import it in replay-header-footers.ts instead of defining it independently in both. * refactor(diff): replace inline margin parsing with readSectPrMargins Remove buildSectionMarginsForAttrs and toInches from replay-header-footers.ts in favor of the canonical readSectPrMargins from sections-xml.ts. * refactor(diff): emit partChanged instead of custom headerFooterPartsChanged event Replace the bespoke headerFooterPartsChanged event with a standard partChanged emission so diff replay reuses the same handler that document-api part mutations already use in PresentationEditor. Add partPath to ModifiedHeaderFooterPart so the replay can build accurate PartChangedEvent entries for every changed part. * feat(diff): thread partsDiff through diff/replay pipeline Add a new `parts` component to the diffing system that will support OOXML part-level and media asset diffs. This commit wires the plumbing end-to-end (compute → replay → summary → service → schema) with placeholder no-op implementations, so subsequent changes can populate the actual diff logic without reshaping the service contract. * feat(diff): implement parts closure capture, diffing, and replay Implement the actual logic for the partsDiff pipeline: - capturePartsState walks header/footer parts and collects their full OPC closure (XML parts, .rels files, and referenced media binaries) - diffParts compares closures between base and target to produce upserts and deletes scoped to header/footer changes - replayPartsDiff applies upserts into convertedXml and media stores, and removes deleted parts - Wire capturePartsState into the diffing extension and diff-service so snapshots include partsState Includes a test verifying header part dependencies (images) round-trip through the diff/replay pipeline. * fix(editor): isolate extension storage across editors sharing the same extension list Clone extension instances during editor creation so each editor gets its own storage objects. Previously, editors constructed from the same extensions array shared mutable storage references, causing one editor's state (e.g. media files) to leak into or be destroyed alongside another. * feat(diff): capture and diff body document.xml.rels closure for media replay Extend parts diffing to cover the document body's relationship closure alongside header/footer closures. When docDiffs are present, the body's document.xml.rels is walked to capture referenced media and their dependencies, excluding parts already handled by dedicated diff channels (styles, numbering, comments, headers/footers, etc.). Includes integration tests verifying body media round-trips through both the direct compare/replay and snapshot-based diff-service paths. * feat(diff): add partsFingerprint to snapshot and diff payload for integrity checks Introduce a separate partsFingerprint (computed over the canonical state including partsState) alongside the existing semantic fingerprint. This lets the diff-service detect when a document's part/media state has drifted even if the semantic content (body, comments, styles) hasn't changed. - captureSnapshot now emits both fingerprint and partsFingerprint - compareToSnapshot re-derives and validates both fingerprints - applyDiffPayload rejects payloads when partsFingerprint mismatches - Schemas and types updated for v2 snapshots/payloads/apply results * fix(diff): prevent deletion of parts still reachable by other closures When removing a header/footer part, check whether its dependencies (media, .rels files) are still referenced by another closure (body or remaining header/footer) before marking them for deletion. This avoids deleting shared assets like images used by multiple headers. * fix(diff): resolve .rels paths relative to the part's own directory toRelsPathForPart previously hardcoded the `word/_rels/` prefix, which broke resolution for nested parts like `word/charts/chart1.xml`. Now it derives the rels path from the part's actual directory. Also skips .rels files themselves to avoid infinite recursion. Adds a unit test verifying nested chart → embedded workbook closure capture through relative relationship targets. * fix(diff): skip partsDiff when partsState is unavailable (legacy callers) Guard diffParts so it returns null when either old or new partsState is missing, which happens when compareDocuments is called without a compare editor. This preserves backward compatibility for legacy callers that don't provide part closure state. * feat(diff): detect and replay header/footer part path renames Track the old part path on modified header/footer parts so the replay can relocate XML and .rels entries when a part's filename changes (e.g. header1.xml → header2.xml) even if the content is identical. The diffing algorithm now treats a part path change as a modification, and replay moves the XML/rels entries and updates the relationship target. * refactor(diff): fold partsState into the main fingerprint instead of a separate partsFingerprint Remove the dedicated partsFingerprint from snapshots, payloads, and apply results. Instead, include partsState in the canonical diffable state used to compute the single fingerprint, so part/media drift is detected by the existing fingerprint mismatch check without adding extra fields to the public API surface. * refactor(diff): unify body and header/footer closure diffing into a single owned-parts strategy Replace the separate body-if-docDiffs and header/footer-if-headerFootersDiff branches with a unified approach: collect all "owned" parts from both closures (excluding semantic roots like document.xml, styles.xml, and header/footer XML files which are handled by their own diff channels), then diff the two owned-part maps to produce upserts and deletes. This simplifies the logic and correctly detects asset-only changes (e.g. an image replacement) even when there are no semantic doc diffs. * feat(diff): emit partChanged event after parts replay Emit a `partChanged` event from `replayPartsDiff` listing all parts that were created, mutated, or deleted during replay. This allows downstream consumers (e.g. the layout engine) to react to part-level changes without polling converter state. * refactor(diff): simplify compareDocuments to accept a single target editor Replace the multi-argument compareDocuments signature (doc, comments, styles, numbering, headerFooters) with a single `targetEditor` param. The command now derives all comparison inputs (comments, styles, numbering, header/footer state, parts state) directly from the target editor, eliminating boilerplate at every call site and ensuring parts state is always captured. * refactor(diff): inline resolveOpcTargetPath and tighten type annotations Copy `resolveOpcTargetPath` into parts-diffing to remove the import dependency on super-converter/helpers, making the diffing module self-contained. Also fix the `cloneExtensionInstance` generic to avoid exposing `constructor` on the public type, add explicit type aliases for header/footer variant IDs and relationship elements, and widen the `ReplayDiffsParams` editor shape to include `state.doc` and `mediaFiles`. * fix(diff): sync converter variant ID caches when replaying slot changes After applying header/footer slot ref changes to the section properties, also update the converter's `headerIds` and `footerIds` caches to match. Without this, downstream code reading variant IDs (e.g. section resolution) would see stale refs after a diff replay repoints a section to a different header or footer. * fix(diff): emit delete+create partChanged events for header/footer path renames When a modified header/footer part has a different path than before, emit a delete for the old path and a create for the new path instead of a single mutate event. This ensures downstream consumers correctly tear down the old part and initialize the new one. * fix(diff): validate that payload coverage matches its declared version Reject diff payloads whose coverage doesn't match the expected profile for their version (e.g. a v1 payload claiming headerFooters coverage). This prevents applying payloads that were manually tampered with or constructed from mismatched version/coverage combinations. * feat(diff): publish replayed media upserts to collaboration Call `addImageToCollaboration` for binary media files under `word/media/` during parts replay, so that images added via diff (e.g. header logos) are synced to other collaboration participants. * refactor(diff): restore resolveOpcTargetPath import and drop stale headerFooterUpdate event Revert the inlined `resolveOpcTargetPath` in favor of the existing import from super-converter/helpers. Also remove the unused `headerFooterUpdate` event emission from header/footer replay, since `partChanged` already covers the notification. * fix(diffing-example): pass target editor to compareDocuments * fix(diffing): mark parts replay as document modified * test: remove logs from test * fix: import error * test: add missing test documents * fix: emit slot clears for removed header/footer sections * refactor: simplify replay parts media store initialization * refactor: remove unused diffParts parameters * refactor: share diffing rels path helper * test: cover replay parts deletions * test: add footer diff replay coverage * test: cover snapshot compare for footer-only diffs * test: add adapter dispatch and doc-api story tests for header/footer diffing - Add diff-adapter.test.ts: verifies createDiffAdapter().apply() dispatches the transaction for header-only diffs when tr.docChanged is false but appliedOperations > 0 - Add header-footer-diff-roundtrip.ts: end-to-end doc-api story that diffs two documents with different headers, applies the diff, saves to DOCX, and verifies header content persists through reopen --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent a4736c8 commit 4b6f30e

32 files changed

Lines changed: 3897 additions & 110 deletions

examples/features/diffing/src/App.vue

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -259,19 +259,9 @@ const compareDocuments = async () => {
259259
createHeadlessEditor(rightFile.value),
260260
]);
261261
262-
const leftDiff = leftEditor.commands.compareDocuments(
263-
rightHeadless.state.doc,
264-
rightHeadless.converter?.comments ?? [],
265-
rightHeadless.converter?.translatedLinkedStyles ?? null,
266-
rightHeadless.converter?.translatedNumbering ?? null,
267-
);
268-
269-
const rightDiff = rightEditor.commands.compareDocuments(
270-
leftHeadless.state.doc,
271-
leftHeadless.converter?.comments ?? [],
272-
leftHeadless.converter?.translatedLinkedStyles ?? null,
273-
leftHeadless.converter?.translatedNumbering ?? null,
274-
);
262+
const leftDiff = leftEditor.commands.compareDocuments(rightHeadless);
263+
264+
const rightDiff = rightEditor.commands.compareDocuments(leftHeadless);
275265
276266
leftEditor.commands.replayDifferences(leftDiff, { applyTrackedChanges: true });
277267
rightEditor.commands.replayDifferences(rightDiff, { applyTrackedChanges: true });

packages/document-api/src/contract/schemas.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,26 +2777,31 @@ const diffCoverageSchema: JsonSchema = objectSchema(
27772777
comments: { type: 'boolean' },
27782778
styles: { type: 'boolean' },
27792779
numbering: { type: 'boolean' },
2780-
headerFooters: { type: 'boolean', const: false },
2780+
headerFooters: { type: 'boolean' },
27812781
},
27822782
['body', 'comments', 'styles', 'numbering', 'headerFooters'],
27832783
);
27842784

27852785
const diffSummarySchema: JsonSchema = objectSchema(
27862786
{
27872787
hasChanges: { type: 'boolean' },
2788-
changedComponents: { type: 'array', items: { type: 'string', enum: ['body', 'comments', 'styles', 'numbering'] } },
2788+
changedComponents: {
2789+
type: 'array',
2790+
items: { type: 'string', enum: ['body', 'comments', 'styles', 'numbering', 'headerFooters', 'parts'] },
2791+
},
27892792
body: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
27902793
comments: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
27912794
styles: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
27922795
numbering: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
2796+
headerFooters: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
2797+
parts: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
27932798
},
2794-
['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering'],
2799+
['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering', 'headerFooters', 'parts'],
27952800
);
27962801

27972802
const diffSnapshotSchema: JsonSchema = objectSchema(
27982803
{
2799-
version: { type: 'string', const: 'sd-diff-snapshot/v1' },
2804+
version: { type: 'string', enum: ['sd-diff-snapshot/v1', 'sd-diff-snapshot/v2'] },
28002805
engine: { type: 'string', enum: ['super-editor'] },
28012806
fingerprint: { type: 'string' },
28022807
coverage: diffCoverageSchema,
@@ -2807,7 +2812,7 @@ const diffSnapshotSchema: JsonSchema = objectSchema(
28072812

28082813
const diffPayloadSchema: JsonSchema = objectSchema(
28092814
{
2810-
version: { type: 'string', const: 'sd-diff-payload/v1' },
2815+
version: { type: 'string', enum: ['sd-diff-payload/v1', 'sd-diff-payload/v2'] },
28112816
engine: { type: 'string', enum: ['super-editor'] },
28122817
baseFingerprint: { type: 'string' },
28132818
targetFingerprint: { type: 'string' },

packages/document-api/src/diff/diff.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import type {
1919
// Constants
2020
// ---------------------------------------------------------------------------
2121

22-
const SNAPSHOT_VERSION = 'sd-diff-snapshot/v1';
23-
const PAYLOAD_VERSION = 'sd-diff-payload/v1';
22+
const SNAPSHOT_VERSIONS = new Set(['sd-diff-snapshot/v1', 'sd-diff-snapshot/v2']);
23+
const PAYLOAD_VERSIONS = new Set(['sd-diff-payload/v1', 'sd-diff-payload/v2']);
2424

2525
// ---------------------------------------------------------------------------
2626
// Adapter interface — implemented by each engine
@@ -54,10 +54,10 @@ function validateSnapshotWrapper(snapshot: unknown): asserts snapshot is DiffSna
5454
if (!isRecord(snapshot)) {
5555
throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot must be a DiffSnapshot object.');
5656
}
57-
if (snapshot.version !== SNAPSHOT_VERSION) {
57+
if (!SNAPSHOT_VERSIONS.has(String(snapshot.version))) {
5858
throw new DocumentApiValidationError(
5959
'CAPABILITY_UNSUPPORTED',
60-
`Unsupported snapshot version "${String(snapshot.version)}". Expected "${SNAPSHOT_VERSION}".`,
60+
`Unsupported snapshot version "${String(snapshot.version)}". Expected one of "${[...SNAPSHOT_VERSIONS].join('", "')}".`,
6161
);
6262
}
6363
if (typeof snapshot.engine !== 'string') {
@@ -78,10 +78,10 @@ function validateDiffPayloadWrapper(diff: unknown): asserts diff is DiffPayload
7878
if (!isRecord(diff)) {
7979
throw new DocumentApiValidationError('INVALID_INPUT', 'diff must be a DiffPayload object.');
8080
}
81-
if (diff.version !== PAYLOAD_VERSION) {
81+
if (!PAYLOAD_VERSIONS.has(String(diff.version))) {
8282
throw new DocumentApiValidationError(
8383
'CAPABILITY_UNSUPPORTED',
84-
`Unsupported diff version "${String(diff.version)}". Expected "${PAYLOAD_VERSION}".`,
84+
`Unsupported diff version "${String(diff.version)}". Expected one of "${[...PAYLOAD_VERSIONS].join('", "')}".`,
8585
);
8686
}
8787
if (typeof diff.engine !== 'string') {

packages/document-api/src/diff/diff.types.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface DiffCoverage {
2323
comments: boolean;
2424
styles: boolean;
2525
numbering: boolean;
26-
headerFooters: false;
26+
headerFooters: boolean;
2727
}
2828

2929
// ---------------------------------------------------------------------------
@@ -32,7 +32,7 @@ export interface DiffCoverage {
3232

3333
/** Versioned, fingerprinted snapshot of a document's diffable state. */
3434
export interface DiffSnapshot {
35-
version: 'sd-diff-snapshot/v1';
35+
version: 'sd-diff-snapshot/v1' | 'sd-diff-snapshot/v2';
3636
engine: DiffEngineId;
3737
fingerprint: string;
3838
coverage: DiffCoverage;
@@ -47,16 +47,18 @@ export interface DiffSnapshot {
4747
/** Coarse change summary for a diff payload. */
4848
export interface DiffSummary {
4949
hasChanges: boolean;
50-
changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering'>;
50+
changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering' | 'headerFooters' | 'parts'>;
5151
body: { hasChanges: boolean };
5252
comments: { hasChanges: boolean };
5353
styles: { hasChanges: boolean };
5454
numbering: { hasChanges: boolean };
55+
headerFooters: { hasChanges: boolean };
56+
parts: { hasChanges: boolean };
5557
}
5658

5759
/** Versioned diff payload describing changes from a base to a target document. */
5860
export interface DiffPayload {
59-
version: 'sd-diff-payload/v1';
61+
version: 'sd-diff-payload/v1' | 'sd-diff-payload/v2';
6062
engine: DiffEngineId;
6163
baseFingerprint: string;
6264
targetFingerprint: string;

packages/super-editor/src/core/Editor.lifecycle.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,46 @@ describe('Editor Lifecycle API', () => {
199199
await editor.open(undefined, getBlankDocOptions());
200200
expect(editor.lifecycleState).toBe('ready');
201201
});
202+
203+
it('isolates extension storage across editors created from the same extension list', async () => {
204+
const sharedExtensions = getStarterExtensions();
205+
const editorA = createTestEditor({ extensions: sharedExtensions });
206+
const editorB = createTestEditor({ extensions: sharedExtensions });
207+
208+
try {
209+
await editorA.open(undefined, getBlankDocOptions());
210+
await editorB.open(undefined, getBlankDocOptions());
211+
212+
editorA.storage.image.media = {
213+
...editorA.storage.image.media,
214+
'word/media/image1.png': 'base64-image-a',
215+
};
216+
217+
editorB.storage.image.media = {
218+
...editorB.storage.image.media,
219+
'word/media/image2.png': 'base64-image-b',
220+
};
221+
222+
expect(editorA.storage.image.media).not.toBe(editorB.storage.image.media);
223+
224+
editorB.destroy();
225+
226+
expect(editorA.storage.image.media['word/media/image1.png']).toBe('base64-image-a');
227+
} finally {
228+
if (!editorA.isDestroyed) {
229+
if (editorA.lifecycleState === 'ready') {
230+
editorA.close();
231+
}
232+
editorA.destroy();
233+
}
234+
if (!editorB.isDestroyed) {
235+
if (editorB.lifecycleState === 'ready') {
236+
editorB.close();
237+
}
238+
editorB.destroy();
239+
}
240+
}
241+
});
202242
});
203243

204244
describe('Source Types', () => {

packages/super-editor/src/core/Editor.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ const PIXELS_PER_INCH = 96;
9797
const MAX_HEIGHT_BUFFER_PX = 50;
9898
const MAX_WIDTH_BUFFER_PX = 20;
9999

100+
type ExtensionInstanceLike = {
101+
type?: string;
102+
config?: Record<string, unknown>;
103+
};
104+
105+
const cloneExtensionInstance = <T>(extension: T): T => {
106+
const extensionLike = extension as ExtensionInstanceLike & {
107+
constructor?: new (config: Record<string, unknown>) => unknown;
108+
};
109+
const config = extensionLike?.config;
110+
const ExtensionCtor = extensionLike?.constructor;
111+
112+
if (!config || typeof config !== 'object' || typeof ExtensionCtor !== 'function') {
113+
return extension;
114+
}
115+
116+
try {
117+
return new ExtensionCtor(config) as T;
118+
} catch {
119+
return extension;
120+
}
121+
};
122+
100123
/**
101124
* Given a table cell node, returns the total cell content width in pixels.
102125
* Sums all colwidth values and subtracts left/right cell margins (padding).
@@ -2003,12 +2026,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
20032026
];
20042027
const externalExtensions = this.options.externalExtensions || [];
20052028

2006-
const allExtensions = [...coreExtensions, ...this.options.extensions!].filter((extension) => {
2007-
const extensionType = typeof extension?.type === 'string' ? extension.type : undefined;
2008-
return extensionType ? allowedExtensions.includes(extensionType) : false;
2009-
});
2029+
const allExtensions = [...coreExtensions, ...this.options.extensions!]
2030+
.filter((extension) => {
2031+
const extensionType = typeof extension?.type === 'string' ? extension.type : undefined;
2032+
return extensionType ? allowedExtensions.includes(extensionType) : false;
2033+
})
2034+
.map((extension) => cloneExtensionInstance(extension));
2035+
2036+
const isolatedExternalExtensions = externalExtensions.map((extension) => cloneExtensionInstance(extension));
20102037

2011-
this.extensionService = ExtensionService.create(allExtensions, externalExtensions, this);
2038+
this.extensionService = ExtensionService.create(allExtensions, isolatedExternalExtensions, this);
20122039
}
20132040

20142041
/**

packages/super-editor/src/core/super-converter/helpers.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,5 @@ export function hasSomeParentWithClass(element: any, classname: any): any;
6666
export function getTextIndentExportValue(indent: string | number): number;
6767
export function polygonUnitsToPixels(pu: any): number;
6868
export function pixelsToPolygonUnits(pixels: any): number;
69+
export function resolveOpcTargetPath(target: string, baseDir?: string): string | null;
6970
//# sourceMappingURL=helpers.d.ts.map
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { Editor } from '@core/Editor.js';
4+
import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js';
5+
import { getStarterExtensions } from '@extensions/index.js';
6+
import { captureSnapshot, compareToSnapshot } from '@extensions/diffing/service/index.ts';
7+
import { createDiffAdapter } from './diff-adapter.ts';
8+
9+
const TEST_USER = { name: 'Test User', email: 'test@example.com' };
10+
11+
async function openBlankEditor(text: string): Promise<Editor> {
12+
const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), {
13+
isHeadless: true,
14+
extensions: getStarterExtensions(),
15+
user: TEST_USER,
16+
});
17+
editor.dispatch(editor.state.tr.insertText(text, 1));
18+
return editor;
19+
}
20+
21+
function createHeaderFooterDoc(editor: Editor, text: string): Record<string, unknown> {
22+
const paragraph = editor.schema.nodes.paragraph.create(
23+
undefined,
24+
editor.schema.nodes.run.create(undefined, text ? [editor.schema.text(text)] : []),
25+
);
26+
return editor.schema.nodes.doc.create(undefined, [paragraph]).toJSON() as Record<string, unknown>;
27+
}
28+
29+
function seedHeader(editor: Editor, refId: string, partPath: string, text: string): void {
30+
const converter = editor.converter!;
31+
const headers = (converter.headers ??= {});
32+
headers[refId] = createHeaderFooterDoc(editor, text);
33+
34+
const headerIds = (converter.headerIds ??= {}) as { ids?: string[]; default?: string | null };
35+
if (!Array.isArray(headerIds.ids)) headerIds.ids = [];
36+
if (!headerIds.ids.includes(refId)) headerIds.ids.push(refId);
37+
38+
const relsPart = (converter.convertedXml!['word/_rels/document.xml.rels'] ??= {
39+
type: 'element',
40+
name: 'document',
41+
elements: [],
42+
}) as { elements?: Array<{ name?: string; attributes?: Record<string, string>; elements?: unknown[] }> };
43+
if (!relsPart.elements) relsPart.elements = [];
44+
45+
let relsRoot = relsPart.elements.find((e) => e.name === 'Relationships');
46+
if (!relsRoot) {
47+
relsRoot = {
48+
name: 'Relationships',
49+
attributes: { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
50+
elements: [],
51+
};
52+
relsPart.elements.push(relsRoot);
53+
}
54+
if (!relsRoot.elements) relsRoot.elements = [];
55+
56+
relsRoot.elements.push({
57+
name: 'Relationship',
58+
attributes: {
59+
Id: refId,
60+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
61+
Target: partPath.replace(/^word\//, ''),
62+
},
63+
elements: [],
64+
});
65+
66+
const sectPrElements: Array<Record<string, unknown>> = [
67+
{ type: 'element', name: 'w:pgSz', attributes: { 'w:w': '12240', 'w:h': '15840' } },
68+
{
69+
type: 'element',
70+
name: 'w:pgMar',
71+
attributes: {
72+
'w:top': '1440',
73+
'w:right': '1440',
74+
'w:bottom': '1440',
75+
'w:left': '1440',
76+
'w:header': '708',
77+
'w:footer': '708',
78+
'w:gutter': '0',
79+
},
80+
},
81+
{ type: 'element', name: 'w:headerReference', attributes: { 'w:type': 'default', 'r:id': refId }, elements: [] },
82+
];
83+
converter.bodySectPr = { type: 'element', name: 'w:sectPr', elements: sectPrElements };
84+
}
85+
86+
describe('createDiffAdapter', () => {
87+
it('dispatches transaction for header-only diffs when document body is unchanged', async () => {
88+
const baseEditor = await openBlankEditor('Same body text.');
89+
const targetEditor = await openBlankEditor('Same body text.');
90+
91+
try {
92+
seedHeader(targetEditor, 'rIdHeader1', 'word/header1.xml', 'New header content');
93+
94+
const snapshot = captureSnapshot(targetEditor);
95+
const diff = compareToSnapshot(baseEditor, snapshot);
96+
97+
expect(diff.summary.body.hasChanges).toBe(false);
98+
expect(diff.summary.headerFooters.hasChanges).toBe(true);
99+
100+
const dispatchSpy = vi.spyOn(baseEditor, 'dispatch');
101+
const adapter = createDiffAdapter(baseEditor);
102+
const result = adapter.apply({ diff }, { changeMode: 'direct' });
103+
104+
expect(result.appliedOperations).toBeGreaterThan(0);
105+
expect(dispatchSpy).toHaveBeenCalledOnce();
106+
} finally {
107+
baseEditor.destroy?.();
108+
targetEditor.destroy?.();
109+
}
110+
});
111+
112+
it('does not dispatch when there are no changes', async () => {
113+
const baseEditor = await openBlankEditor('Identical content.');
114+
const targetEditor = await openBlankEditor('Identical content.');
115+
116+
try {
117+
const snapshot = captureSnapshot(targetEditor);
118+
const diff = compareToSnapshot(baseEditor, snapshot);
119+
120+
expect(diff.summary.hasChanges).toBe(false);
121+
122+
const dispatchSpy = vi.spyOn(baseEditor, 'dispatch');
123+
const adapter = createDiffAdapter(baseEditor);
124+
const result = adapter.apply({ diff }, { changeMode: 'direct' });
125+
126+
expect(result.appliedOperations).toBe(0);
127+
expect(dispatchSpy).not.toHaveBeenCalled();
128+
} finally {
129+
baseEditor.destroy?.();
130+
targetEditor.destroy?.();
131+
}
132+
});
133+
});

packages/super-editor/src/document-api-adapters/diff-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function createDiffAdapter(editor: Editor): DiffAdapter {
3939
apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult {
4040
const { result, tr } = wrapServiceCall(() => applyDiffPayload(editor, input.diff, options));
4141

42-
if (tr.docChanged) {
42+
if (tr.docChanged || result.appliedOperations > 0) {
4343
editor.dispatch(tr);
4444
}
4545

0 commit comments

Comments
 (0)