Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
64177fa
feat(diff): add header/footer diff capture and replay
luccas-harbour Mar 19, 2026
7b88357
feat(editor): refresh presentation after header/footer diff replay
luccas-harbour Mar 19, 2026
54bb09b
fix(diff): restore v1 compatibility and header/footer replay state
luccas-harbour Mar 19, 2026
ae0bd76
fix(diff): gate header/footer capture on target coverage in compareTo…
luccas-harbour Mar 23, 2026
91eb35f
fix(diff): reuse normalizePartPath in replay to fix denormalized rela…
luccas-harbour Mar 23, 2026
7b63cc5
fix(diff): only sync title page cache when slot changes were actually…
luccas-harbour Mar 23, 2026
752441a
refactor(diff): deduplicate SLOT_VARIANTS constant across diffing mod…
luccas-harbour Mar 23, 2026
4461af7
refactor(diff): replace inline margin parsing with readSectPrMargins
luccas-harbour Mar 23, 2026
bc228fa
refactor(diff): emit partChanged instead of custom headerFooterPartsC…
luccas-harbour Mar 23, 2026
325a4b2
feat(diff): thread partsDiff through diff/replay pipeline
luccas-harbour Mar 23, 2026
4dd2dab
feat(diff): implement parts closure capture, diffing, and replay
luccas-harbour Mar 23, 2026
7d2722e
fix(editor): isolate extension storage across editors sharing the sam…
luccas-harbour Mar 23, 2026
09ab7a1
feat(diff): capture and diff body document.xml.rels closure for media…
luccas-harbour Mar 23, 2026
6eef509
feat(diff): add partsFingerprint to snapshot and diff payload for int…
luccas-harbour Mar 23, 2026
40ba287
fix(diff): prevent deletion of parts still reachable by other closures
luccas-harbour Mar 23, 2026
b6087f6
fix(diff): resolve .rels paths relative to the part's own directory
luccas-harbour Mar 23, 2026
58db683
fix(diff): skip partsDiff when partsState is unavailable (legacy call…
luccas-harbour Mar 23, 2026
0ebfe7b
feat(diff): detect and replay header/footer part path renames
luccas-harbour Mar 23, 2026
2785e29
refactor(diff): fold partsState into the main fingerprint instead of …
luccas-harbour Mar 23, 2026
30bfa3a
refactor(diff): unify body and header/footer closure diffing into a s…
luccas-harbour Mar 24, 2026
6d4f1f7
feat(diff): emit partChanged event after parts replay
luccas-harbour Mar 24, 2026
8eb3b93
refactor(diff): simplify compareDocuments to accept a single target e…
luccas-harbour Mar 24, 2026
81c9447
refactor(diff): inline resolveOpcTargetPath and tighten type annotations
luccas-harbour Mar 24, 2026
8f781b6
fix(diff): sync converter variant ID caches when replaying slot changes
luccas-harbour Mar 24, 2026
d9704a3
fix(diff): emit delete+create partChanged events for header/footer pa…
luccas-harbour Mar 24, 2026
5ea5b12
fix(diff): validate that payload coverage matches its declared version
luccas-harbour Mar 24, 2026
ffa32b7
feat(diff): publish replayed media upserts to collaboration
luccas-harbour Mar 24, 2026
128af0d
refactor(diff): restore resolveOpcTargetPath import and drop stale he…
luccas-harbour Mar 24, 2026
561342b
fix(diffing-example): pass target editor to compareDocuments
luccas-harbour Mar 24, 2026
f2303ae
fix(diffing): mark parts replay as document modified
luccas-harbour Mar 24, 2026
8db1033
test: remove logs from test
luccas-harbour Mar 26, 2026
52b7eba
fix: import error
luccas-harbour Mar 26, 2026
30b947f
test: add missing test documents
luccas-harbour Mar 26, 2026
5be612f
fix: emit slot clears for removed header/footer sections
luccas-harbour Mar 26, 2026
93db6e3
refactor: simplify replay parts media store initialization
luccas-harbour Mar 26, 2026
9157945
refactor: remove unused diffParts parameters
luccas-harbour Mar 26, 2026
8fdeca6
refactor: share diffing rels path helper
luccas-harbour Mar 26, 2026
b434afd
test: cover replay parts deletions
luccas-harbour Mar 26, 2026
0a61b31
test: add footer diff replay coverage
luccas-harbour Mar 26, 2026
88210a8
test: cover snapshot compare for footer-only diffs
luccas-harbour Mar 26, 2026
df47aef
test: add adapter dispatch and doc-api story tests for header/footer …
caio-pizzol Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions examples/features/diffing/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -259,19 +259,9 @@ const compareDocuments = async () => {
createHeadlessEditor(rightFile.value),
]);

const leftDiff = leftEditor.commands.compareDocuments(
rightHeadless.state.doc,
rightHeadless.converter?.comments ?? [],
rightHeadless.converter?.translatedLinkedStyles ?? null,
rightHeadless.converter?.translatedNumbering ?? null,
);

const rightDiff = rightEditor.commands.compareDocuments(
leftHeadless.state.doc,
leftHeadless.converter?.comments ?? [],
leftHeadless.converter?.translatedLinkedStyles ?? null,
leftHeadless.converter?.translatedNumbering ?? null,
);
const leftDiff = leftEditor.commands.compareDocuments(rightHeadless);

const rightDiff = rightEditor.commands.compareDocuments(leftHeadless);

leftEditor.commands.replayDifferences(leftDiff, { applyTrackedChanges: true });
rightEditor.commands.replayDifferences(rightDiff, { applyTrackedChanges: true });
Expand Down
15 changes: 10 additions & 5 deletions packages/document-api/src/contract/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@
const trackedChangeAddressSchema = ref('TrackedChangeAddress');
const entityAddressSchema = ref('EntityAddress');
const selectionTargetSchema = ref('SelectionTarget');
const targetLocatorSchema = ref('TargetLocator');

Check warning on line 612 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'targetLocatorSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
const deleteBehaviorSchema = ref('DeleteBehavior');
const resolvedHandleSchema = ref('ResolvedHandle');
const pageInfoSchema = ref('PageInfo');
Expand Down Expand Up @@ -874,7 +874,7 @@
text: { type: 'string' },
});

const nodeInfoSchema: JsonSchema = {

Check warning on line 877 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'nodeInfoSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
type: 'object',
required: ['nodeType', 'kind'],
properties: {
Expand All @@ -890,7 +890,7 @@
additionalProperties: false,
};

const matchContextSchema = objectSchema(

Check warning on line 893 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'matchContextSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
address: nodeAddressSchema,
snippet: { type: 'string' },
Expand All @@ -901,7 +901,7 @@
['address', 'snippet', 'highlightRange'],
);

const unknownNodeDiagnosticSchema = objectSchema(

Check warning on line 904 in packages/document-api/src/contract/schemas.ts

View workflow job for this annotation

GitHub Actions / validate

'unknownNodeDiagnosticSchema' is assigned a value but never used. Allowed unused vars must match /^_/u
{
message: { type: 'string' },
address: nodeAddressSchema,
Expand Down Expand Up @@ -2777,26 +2777,31 @@
comments: { type: 'boolean' },
styles: { type: 'boolean' },
numbering: { type: 'boolean' },
headerFooters: { type: 'boolean', const: false },
headerFooters: { type: 'boolean' },
},
['body', 'comments', 'styles', 'numbering', 'headerFooters'],
);

const diffSummarySchema: JsonSchema = objectSchema(
{
hasChanges: { type: 'boolean' },
changedComponents: { type: 'array', items: { type: 'string', enum: ['body', 'comments', 'styles', 'numbering'] } },
changedComponents: {
type: 'array',
items: { type: 'string', enum: ['body', 'comments', 'styles', 'numbering', 'headerFooters', 'parts'] },
},
body: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
comments: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
styles: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
numbering: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
headerFooters: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
parts: objectSchema({ hasChanges: { type: 'boolean' } }, ['hasChanges']),
},
['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering'],
['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering', 'headerFooters', 'parts'],
);

const diffSnapshotSchema: JsonSchema = objectSchema(
{
version: { type: 'string', const: 'sd-diff-snapshot/v1' },
version: { type: 'string', enum: ['sd-diff-snapshot/v1', 'sd-diff-snapshot/v2'] },
engine: { type: 'string', enum: ['super-editor'] },
fingerprint: { type: 'string' },
coverage: diffCoverageSchema,
Expand All @@ -2807,7 +2812,7 @@

const diffPayloadSchema: JsonSchema = objectSchema(
{
version: { type: 'string', const: 'sd-diff-payload/v1' },
version: { type: 'string', enum: ['sd-diff-payload/v1', 'sd-diff-payload/v2'] },
engine: { type: 'string', enum: ['super-editor'] },
baseFingerprint: { type: 'string' },
targetFingerprint: { type: 'string' },
Expand Down
12 changes: 6 additions & 6 deletions packages/document-api/src/diff/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import type {
// Constants
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// Adapter interface — implemented by each engine
Expand Down Expand Up @@ -54,10 +54,10 @@ function validateSnapshotWrapper(snapshot: unknown): asserts snapshot is DiffSna
if (!isRecord(snapshot)) {
throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot must be a DiffSnapshot object.');
}
if (snapshot.version !== SNAPSHOT_VERSION) {
if (!SNAPSHOT_VERSIONS.has(String(snapshot.version))) {
throw new DocumentApiValidationError(
'CAPABILITY_UNSUPPORTED',
`Unsupported snapshot version "${String(snapshot.version)}". Expected "${SNAPSHOT_VERSION}".`,
`Unsupported snapshot version "${String(snapshot.version)}". Expected one of "${[...SNAPSHOT_VERSIONS].join('", "')}".`,
);
}
if (typeof snapshot.engine !== 'string') {
Expand All @@ -78,10 +78,10 @@ function validateDiffPayloadWrapper(diff: unknown): asserts diff is DiffPayload
if (!isRecord(diff)) {
throw new DocumentApiValidationError('INVALID_INPUT', 'diff must be a DiffPayload object.');
}
if (diff.version !== PAYLOAD_VERSION) {
if (!PAYLOAD_VERSIONS.has(String(diff.version))) {
throw new DocumentApiValidationError(
'CAPABILITY_UNSUPPORTED',
`Unsupported diff version "${String(diff.version)}". Expected "${PAYLOAD_VERSION}".`,
`Unsupported diff version "${String(diff.version)}". Expected one of "${[...PAYLOAD_VERSIONS].join('", "')}".`,
);
}
if (typeof diff.engine !== 'string') {
Expand Down
10 changes: 6 additions & 4 deletions packages/document-api/src/diff/diff.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface DiffCoverage {
comments: boolean;
styles: boolean;
numbering: boolean;
headerFooters: false;
headerFooters: boolean;
}

// ---------------------------------------------------------------------------
Expand All @@ -32,7 +32,7 @@ export interface DiffCoverage {

/** Versioned, fingerprinted snapshot of a document's diffable state. */
export interface DiffSnapshot {
version: 'sd-diff-snapshot/v1';
version: 'sd-diff-snapshot/v1' | 'sd-diff-snapshot/v2';
engine: DiffEngineId;
fingerprint: string;
coverage: DiffCoverage;
Expand All @@ -47,16 +47,18 @@ export interface DiffSnapshot {
/** Coarse change summary for a diff payload. */
export interface DiffSummary {
hasChanges: boolean;
changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering'>;
changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering' | 'headerFooters' | 'parts'>;
body: { hasChanges: boolean };
comments: { hasChanges: boolean };
styles: { hasChanges: boolean };
numbering: { hasChanges: boolean };
headerFooters: { hasChanges: boolean };
parts: { hasChanges: boolean };
}

/** Versioned diff payload describing changes from a base to a target document. */
export interface DiffPayload {
version: 'sd-diff-payload/v1';
version: 'sd-diff-payload/v1' | 'sd-diff-payload/v2';
engine: DiffEngineId;
baseFingerprint: string;
targetFingerprint: string;
Expand Down
40 changes: 40 additions & 0 deletions packages/super-editor/src/core/Editor.lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,46 @@ describe('Editor Lifecycle API', () => {
await editor.open(undefined, getBlankDocOptions());
expect(editor.lifecycleState).toBe('ready');
});

it('isolates extension storage across editors created from the same extension list', async () => {
const sharedExtensions = getStarterExtensions();
const editorA = createTestEditor({ extensions: sharedExtensions });
const editorB = createTestEditor({ extensions: sharedExtensions });

try {
await editorA.open(undefined, getBlankDocOptions());
await editorB.open(undefined, getBlankDocOptions());

editorA.storage.image.media = {
...editorA.storage.image.media,
'word/media/image1.png': 'base64-image-a',
};

editorB.storage.image.media = {
...editorB.storage.image.media,
'word/media/image2.png': 'base64-image-b',
};

expect(editorA.storage.image.media).not.toBe(editorB.storage.image.media);

editorB.destroy();

expect(editorA.storage.image.media['word/media/image1.png']).toBe('base64-image-a');
} finally {
if (!editorA.isDestroyed) {
if (editorA.lifecycleState === 'ready') {
editorA.close();
}
editorA.destroy();
}
if (!editorB.isDestroyed) {
if (editorB.lifecycleState === 'ready') {
editorB.close();
}
editorB.destroy();
}
}
});
});

describe('Source Types', () => {
Expand Down
37 changes: 32 additions & 5 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ const PIXELS_PER_INCH = 96;
const MAX_HEIGHT_BUFFER_PX = 50;
const MAX_WIDTH_BUFFER_PX = 20;

type ExtensionInstanceLike = {
type?: string;
config?: Record<string, unknown>;
};

const cloneExtensionInstance = <T>(extension: T): T => {
const extensionLike = extension as ExtensionInstanceLike & {
constructor?: new (config: Record<string, unknown>) => unknown;
};
const config = extensionLike?.config;
const ExtensionCtor = extensionLike?.constructor;

if (!config || typeof config !== 'object' || typeof ExtensionCtor !== 'function') {
return extension;
}

try {
return new ExtensionCtor(config) as T;
} catch {
return extension;
}
};

/**
* Given a table cell node, returns the total cell content width in pixels.
* Sums all colwidth values and subtracts left/right cell margins (padding).
Expand Down Expand Up @@ -2003,12 +2026,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
];
const externalExtensions = this.options.externalExtensions || [];

const allExtensions = [...coreExtensions, ...this.options.extensions!].filter((extension) => {
const extensionType = typeof extension?.type === 'string' ? extension.type : undefined;
return extensionType ? allowedExtensions.includes(extensionType) : false;
});
const allExtensions = [...coreExtensions, ...this.options.extensions!]
.filter((extension) => {
const extensionType = typeof extension?.type === 'string' ? extension.type : undefined;
return extensionType ? allowedExtensions.includes(extensionType) : false;
})
.map((extension) => cloneExtensionInstance(extension));

const isolatedExternalExtensions = externalExtensions.map((extension) => cloneExtensionInstance(extension));

this.extensionService = ExtensionService.create(allExtensions, externalExtensions, this);
this.extensionService = ExtensionService.create(allExtensions, isolatedExternalExtensions, this);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,5 @@ export function hasSomeParentWithClass(element: any, classname: any): any;
export function getTextIndentExportValue(indent: string | number): number;
export function polygonUnitsToPixels(pu: any): number;
export function pixelsToPolygonUnits(pixels: any): number;
export function resolveOpcTargetPath(target: string, baseDir?: string): string | null;
//# sourceMappingURL=helpers.d.ts.map
133 changes: 133 additions & 0 deletions packages/super-editor/src/document-api-adapters/diff-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';

import { Editor } from '@core/Editor.js';
import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js';
import { getStarterExtensions } from '@extensions/index.js';
import { captureSnapshot, compareToSnapshot } from '@extensions/diffing/service/index.ts';
import { createDiffAdapter } from './diff-adapter.ts';

const TEST_USER = { name: 'Test User', email: 'test@example.com' };

async function openBlankEditor(text: string): Promise<Editor> {
const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), {
isHeadless: true,
extensions: getStarterExtensions(),
user: TEST_USER,
});
editor.dispatch(editor.state.tr.insertText(text, 1));
return editor;
}

function createHeaderFooterDoc(editor: Editor, text: string): Record<string, unknown> {
const paragraph = editor.schema.nodes.paragraph.create(
undefined,
editor.schema.nodes.run.create(undefined, text ? [editor.schema.text(text)] : []),
);
return editor.schema.nodes.doc.create(undefined, [paragraph]).toJSON() as Record<string, unknown>;
}

function seedHeader(editor: Editor, refId: string, partPath: string, text: string): void {
const converter = editor.converter!;
const headers = (converter.headers ??= {});
headers[refId] = createHeaderFooterDoc(editor, text);

const headerIds = (converter.headerIds ??= {}) as { ids?: string[]; default?: string | null };
if (!Array.isArray(headerIds.ids)) headerIds.ids = [];
if (!headerIds.ids.includes(refId)) headerIds.ids.push(refId);

const relsPart = (converter.convertedXml!['word/_rels/document.xml.rels'] ??= {
type: 'element',
name: 'document',
elements: [],
}) as { elements?: Array<{ name?: string; attributes?: Record<string, string>; elements?: unknown[] }> };
if (!relsPart.elements) relsPart.elements = [];

let relsRoot = relsPart.elements.find((e) => e.name === 'Relationships');
if (!relsRoot) {
relsRoot = {
name: 'Relationships',
attributes: { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
elements: [],
};
relsPart.elements.push(relsRoot);
}
if (!relsRoot.elements) relsRoot.elements = [];

relsRoot.elements.push({
name: 'Relationship',
attributes: {
Id: refId,
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
Target: partPath.replace(/^word\//, ''),
},
elements: [],
});

const sectPrElements: Array<Record<string, unknown>> = [
{ type: 'element', name: 'w:pgSz', attributes: { 'w:w': '12240', 'w:h': '15840' } },
{
type: 'element',
name: 'w:pgMar',
attributes: {
'w:top': '1440',
'w:right': '1440',
'w:bottom': '1440',
'w:left': '1440',
'w:header': '708',
'w:footer': '708',
'w:gutter': '0',
},
},
{ type: 'element', name: 'w:headerReference', attributes: { 'w:type': 'default', 'r:id': refId }, elements: [] },
];
converter.bodySectPr = { type: 'element', name: 'w:sectPr', elements: sectPrElements };
}

describe('createDiffAdapter', () => {
it('dispatches transaction for header-only diffs when document body is unchanged', async () => {
const baseEditor = await openBlankEditor('Same body text.');
const targetEditor = await openBlankEditor('Same body text.');

try {
seedHeader(targetEditor, 'rIdHeader1', 'word/header1.xml', 'New header content');

const snapshot = captureSnapshot(targetEditor);
const diff = compareToSnapshot(baseEditor, snapshot);

expect(diff.summary.body.hasChanges).toBe(false);
expect(diff.summary.headerFooters.hasChanges).toBe(true);

const dispatchSpy = vi.spyOn(baseEditor, 'dispatch');
const adapter = createDiffAdapter(baseEditor);
const result = adapter.apply({ diff }, { changeMode: 'direct' });

expect(result.appliedOperations).toBeGreaterThan(0);
expect(dispatchSpy).toHaveBeenCalledOnce();
} finally {
baseEditor.destroy?.();
targetEditor.destroy?.();
}
});

it('does not dispatch when there are no changes', async () => {
const baseEditor = await openBlankEditor('Identical content.');
const targetEditor = await openBlankEditor('Identical content.');

try {
const snapshot = captureSnapshot(targetEditor);
const diff = compareToSnapshot(baseEditor, snapshot);

expect(diff.summary.hasChanges).toBe(false);

const dispatchSpy = vi.spyOn(baseEditor, 'dispatch');
const adapter = createDiffAdapter(baseEditor);
const result = adapter.apply({ diff }, { changeMode: 'direct' });

expect(result.appliedOperations).toBe(0);
expect(dispatchSpy).not.toHaveBeenCalled();
} finally {
baseEditor.destroy?.();
targetEditor.destroy?.();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function createDiffAdapter(editor: Editor): DiffAdapter {
apply(input: DiffApplyInput, options?: DiffApplyOptions): DiffApplyResult {
const { result, tr } = wrapServiceCall(() => applyDiffPayload(editor, input.diff, options));

if (tr.docChanged) {
if (tr.docChanged || result.appliedOperations > 0) {
editor.dispatch(tr);
}

Expand Down
Loading
Loading