From 64177fa04c1dfe36d4da46355d1f276a5c07fd9f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 15:10:19 -0300 Subject: [PATCH 01/41] feat(diff): add header/footer diff capture and replay --- packages/document-api/src/contract/schemas.ts | 14 +- packages/document-api/src/diff/diff.ts | 12 +- packages/document-api/src/diff/diff.types.ts | 9 +- .../algorithm/header-footer-diffing.ts | 328 +++++++++ .../src/extensions/diffing/computeDiff.ts | 8 + .../src/extensions/diffing/diffing.js | 23 +- .../extensions/diffing/headerFooters.test.ts | 344 ++++++++++ .../diffing/replay/replay-header-footers.ts | 636 ++++++++++++++++++ .../src/extensions/diffing/replayDiffs.ts | 48 +- .../diffing/service/canonicalize.ts | 4 + .../extensions/diffing/service/coverage.ts | 13 +- .../diffing/service/diff-service.ts | 140 +++- .../src/extensions/diffing/service/index.ts | 2 +- .../src/extensions/diffing/service/summary.ts | 3 + .../src/dev/components/SuperdocDev.vue | 1 + 15 files changed, 1544 insertions(+), 41 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts create mode 100644 packages/super-editor/src/extensions/diffing/headerFooters.test.ts create mode 100644 packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 5d3c699016..48b949b665 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2777,7 +2777,7 @@ const diffCoverageSchema: JsonSchema = objectSchema( comments: { type: 'boolean' }, styles: { type: 'boolean' }, numbering: { type: 'boolean' }, - headerFooters: { type: 'boolean', const: false }, + headerFooters: { type: 'boolean' }, }, ['body', 'comments', 'styles', 'numbering', 'headerFooters'], ); @@ -2785,18 +2785,22 @@ const diffCoverageSchema: JsonSchema = objectSchema( 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'] }, + }, 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']), }, - ['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering'], + ['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering', 'headerFooters'], ); 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, @@ -2807,7 +2811,7 @@ const diffSnapshotSchema: JsonSchema = objectSchema( 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' }, diff --git a/packages/document-api/src/diff/diff.ts b/packages/document-api/src/diff/diff.ts index 26a23488a2..55b8d31f2f 100644 --- a/packages/document-api/src/diff/diff.ts +++ b/packages/document-api/src/diff/diff.ts @@ -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 @@ -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') { @@ -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') { diff --git a/packages/document-api/src/diff/diff.types.ts b/packages/document-api/src/diff/diff.types.ts index af0f7dc02f..8d8debc0d9 100644 --- a/packages/document-api/src/diff/diff.types.ts +++ b/packages/document-api/src/diff/diff.types.ts @@ -23,7 +23,7 @@ export interface DiffCoverage { comments: boolean; styles: boolean; numbering: boolean; - headerFooters: false; + headerFooters: boolean; } // --------------------------------------------------------------------------- @@ -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; @@ -47,16 +47,17 @@ 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'>; body: { hasChanges: boolean }; comments: { hasChanges: boolean }; styles: { hasChanges: boolean }; numbering: { hasChanges: boolean }; + headerFooters: { 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; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts new file mode 100644 index 0000000000..b40c842265 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -0,0 +1,328 @@ +import type { Node as PMNode, Schema } from 'prosemirror-model'; +import { diffNodes, normalizeNodes, type NodeDiff } from './generic-diffing'; +import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js'; +import { readTargetSectPr } from '../../../document-api-adapters/helpers/section-projection-access.js'; +import { readSectPrHeaderFooterRefs } from '../../../document-api-adapters/helpers/sections-xml.js'; + +/** + * Header/footer kind names used throughout the diff payload. + */ +export type HeaderFooterKind = 'header' | 'footer'; + +/** + * Explicit slot variants supported by section properties. + */ +export type HeaderFooterVariant = 'default' | 'first' | 'even'; + +/** + * Serialized header/footer part state captured from one editor. + */ +export interface HeaderFooterPartState { + refId: string; + kind: HeaderFooterKind; + partPath: string; + content: Record; +} + +/** + * Serialized section slot state captured from one editor. + */ +export interface HeaderFooterSlotState { + sectionId: string; + titlePg: boolean; + header: Record; + footer: Record; +} + +/** + * Canonical header/footer state captured from one editor. + */ +export interface HeaderFooterState { + parts: HeaderFooterPartState[]; + slots: HeaderFooterSlotState[]; +} + +/** + * Content diff for one existing header/footer part. + */ +export interface ModifiedHeaderFooterPart { + refId: string; + kind: HeaderFooterKind; + docDiffs: NodeDiff[]; +} + +/** + * Full header/footer diff payload. + */ +export interface HeaderFootersDiff { + addedParts: HeaderFooterPartState[]; + removedParts: HeaderFooterPartState[]; + modifiedParts: ModifiedHeaderFooterPart[]; + slotChanges: HeaderFooterSlotState[]; +} + +type HeaderFooterEditor = { + state: { doc: PMNode }; + converter?: { + headers?: Record; + footers?: Record; + convertedXml?: Record; + } | null; +}; + +const SLOT_VARIANTS: HeaderFooterVariant[] = ['default', 'first', 'even']; +const PART_KINDS: HeaderFooterKind[] = ['header', 'footer']; + +/** + * Captures the header/footer state needed by snapshotting, diffing, and replay. + * + * @param editor Editor instance whose converter and section XML should be read. + * @returns Canonical header/footer state for the editor. + */ +export function captureHeaderFooterState(editor: HeaderFooterEditor): HeaderFooterState { + return { + parts: collectHeaderFooterParts(editor), + slots: collectHeaderFooterSlots(editor), + }; +} + +/** + * Computes the header/footer diff between two captured states. + * + * @param oldState Previous header/footer state. + * @param newState Updated header/footer state. + * @param schema Schema used to rebuild stored PM JSON. + * @returns Header/footer diff, or `null` when no changes were detected. + */ +export function diffHeaderFooters( + oldState: HeaderFooterState | null | undefined, + newState: HeaderFooterState | null | undefined, + schema: Schema, +): HeaderFootersDiff | null { + const previous = oldState ?? { parts: [], slots: [] }; + const next = newState ?? { parts: [], slots: [] }; + const previousParts = new Map(previous.parts.map((part) => [part.refId, part])); + const nextParts = new Map(next.parts.map((part) => [part.refId, part])); + + const addedParts: HeaderFooterPartState[] = []; + const removedParts: HeaderFooterPartState[] = []; + const modifiedParts: ModifiedHeaderFooterPart[] = []; + + for (const nextPart of next.parts) { + const previousPart = previousParts.get(nextPart.refId); + if (!previousPart) { + addedParts.push(structuredClone(nextPart)); + continue; + } + if (previousPart.kind !== nextPart.kind) { + removedParts.push(structuredClone(previousPart)); + addedParts.push(structuredClone(nextPart)); + continue; + } + + const oldDoc = schema.nodeFromJSON(previousPart.content); + const newDoc = schema.nodeFromJSON(nextPart.content); + const docDiffs = diffNodes(normalizeNodes(oldDoc), normalizeNodes(newDoc)); + if (docDiffs.length > 0) { + modifiedParts.push({ + refId: nextPart.refId, + kind: nextPart.kind, + docDiffs, + }); + } + } + + for (const previousPart of previous.parts) { + if (!nextParts.has(previousPart.refId)) { + removedParts.push(structuredClone(previousPart)); + } + } + + const previousSlots = new Map(previous.slots.map((slot) => [slot.sectionId, slot])); + const slotChanges: HeaderFooterSlotState[] = []; + for (const nextSlot of next.slots) { + const previousSlot = previousSlots.get(nextSlot.sectionId); + if (!slotsEqual(previousSlot, nextSlot)) { + slotChanges.push(structuredClone(nextSlot)); + } + } + + if (addedParts.length === 0 && removedParts.length === 0 && modifiedParts.length === 0 && slotChanges.length === 0) { + return null; + } + + return { + addedParts, + removedParts, + modifiedParts, + slotChanges, + }; +} + +/** + * Builds the part snapshot list from converter header/footer collections. + * + * @param editor Editor whose converter collections should be read. + * @returns Sorted part snapshot list. + */ +function collectHeaderFooterParts(editor: HeaderFooterEditor): HeaderFooterPartState[] { + const parts: HeaderFooterPartState[] = []; + const partPaths = readHeaderFooterPartPaths(editor); + + for (const kind of PART_KINDS) { + const source = kind === 'header' ? editor.converter?.headers : editor.converter?.footers; + if (!source) continue; + + for (const [refId, content] of Object.entries(source)) { + const partPath = partPaths.get(refId); + if (!partPath || !content || typeof content !== 'object') continue; + parts.push({ + refId, + kind, + partPath, + content: structuredClone(content as Record), + }); + } + } + + return parts.sort(compareParts); +} + +/** + * Reads relationship targets for all header/footer references in the document. + * + * @param editor Editor whose `document.xml.rels` should be inspected. + * @returns Map from relationship id to normalized OOXML part path. + */ +function readHeaderFooterPartPaths(editor: HeaderFooterEditor): Map { + const result = new Map(); + const relsPart = editor.converter?.convertedXml?.['word/_rels/document.xml.rels'] as + | { + elements?: Array<{ + name?: string; + attributes?: Record; + elements?: Array<{ name?: string; attributes?: Record }>; + }>; + } + | undefined; + const relsRoot = relsPart?.elements?.find((entry) => entry.name === 'Relationships'); + if (!relsRoot?.elements) { + return result; + } + + for (const entry of relsRoot.elements) { + const type = entry.attributes?.Type; + if ( + entry.name !== 'Relationship' || + (type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header' && + type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer') + ) { + continue; + } + + const refId = entry.attributes?.Id; + const target = entry.attributes?.Target; + if (!refId || !target) { + continue; + } + + result.set(refId, normalizePartPath(target)); + } + + return result; +} + +/** + * Normalizes a relationship target into a `word/...` OOXML part path. + * + * @param target Raw relationship target string. + * @returns Normalized OOXML part path. + */ +function normalizePartPath(target: string): string { + let normalized = target.replace(/^\.\//, ''); + if (normalized.startsWith('../')) { + normalized = normalized.slice(3); + } + if (normalized.startsWith('/')) { + normalized = normalized.slice(1); + } + if (!normalized.startsWith('word/')) { + normalized = `word/${normalized}`; + } + return normalized; +} + +/** + * Builds the section slot snapshot list from section properties. + * + * @param editor Editor whose section projections should be read. + * @returns Sorted slot snapshot list. + */ +function collectHeaderFooterSlots(editor: HeaderFooterEditor): HeaderFooterSlotState[] { + const slots: HeaderFooterSlotState[] = []; + + for (const projection of resolveSectionProjections(editor as never)) { + const sectPr = readTargetSectPr(editor as never, projection); + const headerRefs = sectPr ? readSectPrHeaderFooterRefs(sectPr, 'header') : undefined; + const footerRefs = sectPr ? readSectPrHeaderFooterRefs(sectPr, 'footer') : undefined; + + slots.push({ + sectionId: projection.sectionId, + titlePg: projection.range.titlePg === true, + header: { + default: headerRefs?.default ?? null, + first: headerRefs?.first ?? null, + even: headerRefs?.even ?? null, + }, + footer: { + default: footerRefs?.default ?? null, + first: footerRefs?.first ?? null, + even: footerRefs?.even ?? null, + }, + }); + } + + return slots.sort((a, b) => a.sectionId.localeCompare(b.sectionId)); +} + +/** + * Compares two part entries in stable order. + * + * @param a First part entry. + * @param b Second part entry. + * @returns Sort order. + */ +function compareParts(a: HeaderFooterPartState, b: HeaderFooterPartState): number { + if (a.kind !== b.kind) { + return a.kind.localeCompare(b.kind); + } + return a.refId.localeCompare(b.refId); +} + +/** + * Compares two slot states by value. + * + * @param previous Previous slot state. + * @param next Next slot state. + * @returns `true` when both slot states are equivalent. + */ +function slotsEqual(previous: HeaderFooterSlotState | undefined, next: HeaderFooterSlotState): boolean { + if (!previous) { + return false; + } + + if (previous.titlePg !== next.titlePg) { + return false; + } + + for (const variant of SLOT_VARIANTS) { + if (previous.header[variant] !== next.header[variant]) { + return false; + } + if (previous.footer[variant] !== next.footer[variant]) { + return false; + } + } + + return true; +} diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 4f6c40b554..317138c855 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -1,6 +1,7 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import { diffComments, type CommentInput, type CommentDiff } from './algorithm/comment-diffing'; +import { diffHeaderFooters, type HeaderFooterState, type HeaderFootersDiff } from './algorithm/header-footer-diffing'; import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing'; import { diffStyles, type StylesDiff } from './algorithm/styles-diffing'; import { diffNumbering, type NumberingDiff } from './algorithm/numbering-diffing'; @@ -17,6 +18,8 @@ export interface DiffResult { stylesDiff: StylesDiff | null; /** Diffs computed from OOXML numbering metadata. */ numberingDiff: NumberingDiff | null; + /** Diffs computed from header/footer parts and section slot refs. */ + headerFootersDiff: HeaderFootersDiff | null; } /** @@ -38,6 +41,8 @@ export interface DiffResult { * @param newStyles OOXML style snapshot from the new document. * @param oldNumbering OOXML numbering snapshot from the old document. * @param newNumbering OOXML numbering snapshot from the new document. + * @param oldHeaderFooters Header/footer snapshot from the old document. + * @param newHeaderFooters Header/footer snapshot from the new document. * @returns Object containing document, comment, style, and numbering diffs. */ export function computeDiff( @@ -50,11 +55,14 @@ export function computeDiff( newStyles: StylesDocumentProperties | null | undefined = null, oldNumbering: NumberingProperties | null | undefined = null, newNumbering: NumberingProperties | null | undefined = null, + oldHeaderFooters: HeaderFooterState | null | undefined = null, + newHeaderFooters: HeaderFooterState | null | undefined = null, ): DiffResult { return { docDiffs: diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)), commentDiffs: diffComments(oldComments, newComments, schema), stylesDiff: diffStyles(oldStyles, newStyles), numberingDiff: diffNumbering(oldNumbering, newNumbering), + headerFootersDiff: diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema), }; } diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index 4f679e327c..ce212e9a73 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -2,6 +2,7 @@ import { Extension } from '@core/Extension.js'; import { computeDiff } from './computeDiff.ts'; import { replayDiffs } from './replayDiffs.ts'; +import { captureHeaderFooterState } from './algorithm/header-footer-diffing.ts'; export const Diffing = Extension.create({ name: 'documentDiffing', @@ -20,10 +21,11 @@ export const Diffing = Extension.create({ * @param {import('./algorithm/comment-diffing.ts').CommentInput[]} [updatedComments] * @param {import('@superdoc/style-engine/ooxml').StylesDocumentProperties | null} [updatedStyles] * @param {import('@superdoc/style-engine/ooxml').NumberingProperties | null} [updatedNumbering] + * @param {import('./algorithm/header-footer-diffing.ts').HeaderFooterState | { state?: unknown; converter?: unknown } | null} [updatedHeaderFooters] * @returns {import('./computeDiff.ts').DiffResult} */ compareDocuments: - (updatedDocument, updatedComments, updatedStyles, updatedNumbering) => + (updatedDocument, updatedComments, updatedStyles, updatedNumbering, updatedHeaderFooters) => ({ state, tr }) => { tr.setMeta('preventDispatch', true); const currentComments = this.editor.converter?.comments ?? []; @@ -32,6 +34,13 @@ export const Diffing = Extension.create({ const nextStyles = updatedStyles === undefined ? currentStyles : updatedStyles; const currentNumbering = this.editor.converter?.translatedNumbering ?? null; const nextNumbering = updatedNumbering === undefined ? currentNumbering : updatedNumbering; + const currentHeaderFooters = captureHeaderFooterState(this.editor); + const nextHeaderFooters = + updatedHeaderFooters === undefined + ? currentHeaderFooters + : updatedHeaderFooters?.state && updatedHeaderFooters?.converter + ? captureHeaderFooterState(updatedHeaderFooters) + : updatedHeaderFooters; const diffs = computeDiff( state.doc, updatedDocument, @@ -42,6 +51,8 @@ export const Diffing = Extension.create({ nextStyles, currentNumbering, nextNumbering, + currentHeaderFooters, + nextHeaderFooters, ); return diffs; }, @@ -68,13 +79,19 @@ export const Diffing = Extension.create({ const tr = state.tr; const canApplyTrackedChanges = applyTrackedChanges && Boolean(this.editor.options.user); + if (canApplyTrackedChanges) { + // Diff replay can add pagination and section metadata to the transaction. + // Marking it as programmatic keeps tracked replay enabled for body steps. + tr.setMeta('inputType', 'programmatic'); + } - replayDiffs({ + const replayResult = replayDiffs({ tr, diff, schema: state.schema, comments, editor: this.editor, + trackedChangesRequested: canApplyTrackedChanges, }); if (canApplyTrackedChanges) { tr.setMeta('forceTrackChanges', true); @@ -82,7 +99,7 @@ export const Diffing = Extension.create({ tr.setMeta('skipTrackChanges', true); } - if (dispatch && tr.docChanged) { + if (dispatch && (tr.docChanged || replayResult.appliedDiffs > 0)) { dispatch(tr); } diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts new file mode 100644 index 0000000000..0e5fb94ab0 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Editor } from '@core/Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; +import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import { captureHeaderFooterState } from './algorithm/header-footer-diffing'; + +/** + * Creates a headless editor from a DOCX fixture. + * + * @param user Optional user config for tracked replay tests. + * @returns Headless editor ready for diffing tests. + */ +async function createEditor(user?: { name: string; email: string }): Promise { + const buffer = await getTestDataAsBuffer('diffing/diff_before2.docx'); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + return new Editor({ + isHeadless: true, + extensions: getStarterExtensions(), + documentId: 'header-footer-diff-test', + content: docx, + mode: 'docx', + media, + mediaFiles, + fonts, + annotations: true, + user, + }); +} + +/** + * Builds a simple PM JSON document for header/footer content. + * + * @param editor Editor whose schema should be used. + * @param text Plain text content for the document. + * @returns PM JSON document with one paragraph. + */ +function createHeaderFooterDoc(editor: Editor, text: string): Record { + 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; +} + +/** + * Seeds one header/footer part into converter state and document relationships. + * + * @param editor Editor whose converter should be updated. + * @param params Header/footer part settings. + */ +function seedPart( + editor: Editor, + params: { kind: 'header' | 'footer'; refId: string; partPath: string; text: string }, +): void { + const { kind, refId, partPath, text } = params; + const converter = editor.converter!; + const collection = kind === 'header' ? (converter.headers ??= {}) : (converter.footers ??= {}); + collection[refId] = createHeaderFooterDoc(editor, text); + + const variantIds = kind === 'header' ? (converter.headerIds ??= {}) : (converter.footerIds ??= {}); + if (!Array.isArray(variantIds.ids)) { + variantIds.ids = []; + } + if (!variantIds.ids.includes(refId)) { + variantIds.ids.push(refId); + } + + if (!converter.convertedXml?.[partPath]) { + converter.convertedXml![partPath] = { + type: 'element', + name: 'document', + elements: [ + { + type: 'element', + name: kind === 'header' ? 'w:hdr' : 'w:ftr', + elements: [], + }, + ], + }; + } + + const relsPart = (converter.convertedXml!['word/_rels/document.xml.rels'] ??= { + type: 'element', + name: 'document', + elements: [], + }) as { elements?: Array<{ name?: string; attributes?: Record; elements?: unknown[] }> }; + if (!relsPart.elements) { + relsPart.elements = []; + } + let relsRoot = relsPart.elements.find((entry) => entry.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 = []; + } + + const existing = relsRoot.elements.find( + (entry) => entry?.name === 'Relationship' && entry.attributes?.Id === refId, + ) as { attributes?: Record } | undefined; + const attributes = { + Id: refId, + Type: + kind === 'header' + ? 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header' + : 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', + Target: partPath.replace(/^word\//, ''), + }; + + if (existing) { + existing.attributes = attributes; + } else { + relsRoot.elements.push({ + name: 'Relationship', + attributes, + elements: [], + }); + } +} + +/** + * Writes the body section properties used by the section resolver. + * + * @param editor Editor whose body section properties should be updated. + * @param params Explicit section references to set. + */ +function setBodySection( + editor: Editor, + params: { + titlePg?: boolean; + headerDefault?: string | null; + footerDefault?: string | null; + }, +): void { + const elements: Array> = [ + { + 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', + }, + }, + ]; + + if (params.titlePg) { + elements.push({ type: 'element', name: 'w:titlePg', elements: [] }); + } + if (params.headerDefault) { + elements.push({ + type: 'element', + name: 'w:headerReference', + attributes: { 'w:type': 'default', 'r:id': params.headerDefault }, + elements: [], + }); + } + if (params.footerDefault) { + elements.push({ + type: 'element', + name: 'w:footerReference', + attributes: { 'w:type': 'default', 'r:id': params.footerDefault }, + elements: [], + }); + } + + editor.converter!.bodySectPr = { + type: 'element', + name: 'w:sectPr', + elements, + }; +} + +/** + * Seeds one default header for a single-section test document. + * + * @param editor Editor whose converter should be updated. + * @param text Header text content. + */ +function seedDefaultHeader(editor: Editor, text: string): void { + seedPart(editor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header1.xml', + text, + }); + setBodySection(editor, { headerDefault: 'rIdHeader1' }); +} + +describe('Header/footer diffing', () => { + it('compares and replays a newly added header', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultHeader(afterEditor, 'New header'); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.headerFootersDiff?.addedParts).toHaveLength(1); + expect(diff.headerFootersDiff?.slotChanges).toHaveLength(1); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(captureHeaderFooterState(beforeEditor)).toEqual(captureHeaderFooterState(afterEditor)); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + + it('emits a header/footer refresh signal when replay adds a new header', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultHeader(afterEditor, 'New header'); + + const emitSpy = vi.spyOn(beforeEditor, 'emit'); + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + 'headerFooterPartsChanged', + expect.objectContaining({ + addedParts: ['rIdHeader1'], + }), + ); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + + it('exports a valid header part after replay adds a new header', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultHeader(afterEditor, 'Exported header'); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + + const updatedDocs = await beforeEditor.exportDocx({ getUpdatedDocs: true }); + expect(updatedDocs['word/header1.xml']).toContain(' { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedDefaultHeader(beforeEditor, 'Old header'); + seedDefaultHeader(afterEditor, 'Updated header'); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.headerFootersDiff?.modifiedParts).toHaveLength(1); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(captureHeaderFooterState(beforeEditor)).toEqual(captureHeaderFooterState(afterEditor)); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + + it('compares and replays header removal', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedDefaultHeader(beforeEditor, 'Remove me'); + setBodySection(afterEditor, {}); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.headerFootersDiff?.removedParts).toHaveLength(1); + expect(diff.headerFootersDiff?.slotChanges).toHaveLength(1); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(captureHeaderFooterState(beforeEditor)).toEqual(captureHeaderFooterState(afterEditor)); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts new file mode 100644 index 0000000000..14b7484c6f --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -0,0 +1,636 @@ +import { EditorState, type Transaction } from 'prosemirror-state'; +import type { Schema } from 'prosemirror-model'; +import { replayDocDiffs } from './replay-doc'; +import { ReplayResult } from './replay-types'; +import type { + HeaderFootersDiff, + HeaderFooterKind, + HeaderFooterPartState, + HeaderFooterSlotState, + HeaderFooterVariant, +} from '../algorithm/header-footer-diffing'; +import { resolveSectionProjections } from '../../../document-api-adapters/helpers/sections-resolver.js'; +import { readTargetSectPr } from '../../../document-api-adapters/helpers/section-projection-access.js'; +import { + ensureSectPrElement, + cloneXmlElement, + clearSectPrHeaderFooterRef, + setSectPrHeaderFooterRef, + writeSectPrTitlePage, +} from '../../../document-api-adapters/helpers/sections-xml.js'; +import { DEFAULT_DOCX_DEFS } from '../../../core/super-converter/exporter-docx-defs.js'; + +type ReplayHeaderFooterEditor = { + state: { doc: import('prosemirror-model').Node }; + emit?: (event: string, payload?: unknown) => void; + converter?: { + headers?: Record; + footers?: Record; + headerIds?: Record; + footerIds?: Record; + convertedXml?: Record; + bodySectPr?: unknown; + savedTagsToRestore?: Array>; + exportToXmlJson?: (opts: { + data: unknown; + editor: { schema: Schema; getUpdatedJson: () => unknown }; + editorSchema: Schema; + isHeaderFooter: boolean; + comments?: unknown[]; + commentDefinitions?: unknown[]; + isFinalDoc?: boolean; + }) => { + result?: { elements?: Array<{ elements?: unknown[] }> }; + }; + headerFooterModified?: boolean; + documentModified?: boolean; + } | null; +}; + +const SLOT_VARIANTS: HeaderFooterVariant[] = ['default', 'first', 'even']; +const HEADER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; +const FOOTER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; + +/** + * Replays header/footer diffs into the current editor state. + * + * @param params Replay inputs. + * @param params.tr Transaction that should receive section slot mutations. + * @param params.headerFootersDiff Header/footer diff payload to apply. + * @param params.schema Schema used to rebuild stored PM JSON documents. + * @param params.editor Editor whose converter caches should be updated. + * @param params.trackedChangesRequested Whether the outer replay requested tracked mode. + * @returns Replay summary with applied/skipped counts and warnings. + */ +export function replayHeaderFooters({ + tr, + headerFootersDiff, + schema, + editor, + trackedChangesRequested = false, +}: { + tr: Transaction; + headerFootersDiff: HeaderFootersDiff | null; + schema: Schema; + editor?: ReplayHeaderFooterEditor; + trackedChangesRequested?: boolean; +}): ReplayResult { + const result: ReplayResult = { + applied: 0, + skipped: 0, + warnings: [], + }; + + if (!headerFootersDiff) { + return result; + } + + if (!editor?.converter) { + result.skipped += 1; + result.warnings.push('Header/footer replay skipped: editor converter is unavailable.'); + return result; + } + + if (trackedChangesRequested) { + result.warnings.push( + 'Header/footer replay applied directly because tracked header/footer replay is not supported.', + ); + } + + ensureHeaderFooterCollections(editor.converter); + + for (const part of headerFootersDiff.addedParts) { + createHeaderFooterPart(editor.converter, schema, part); + result.applied += 1; + } + + for (const part of headerFootersDiff.modifiedParts) { + const updated = applyHeaderFooterPartContent(editor.converter, schema, part.refId, part.kind, part.docDiffs); + if (updated) { + result.applied += 1; + continue; + } + result.skipped += 1; + result.warnings.push(`Header/footer replay skipped for "${part.refId}": stored part content was not found.`); + } + + for (const slot of headerFootersDiff.slotChanges) { + const applied = applyHeaderFooterSlotChange(tr, editor, slot); + if (applied) { + result.applied += 1; + continue; + } + result.skipped += 1; + result.warnings.push( + `Header/footer replay skipped for section "${slot.sectionId}": section projection was not found.`, + ); + } + + for (const part of headerFootersDiff.removedParts) { + deleteHeaderFooterPart(editor.converter, part); + result.applied += 1; + } + + if (result.applied > 0) { + tr.setMeta('forceUpdatePagination', true); + editor.converter.headerFooterModified = true; + editor.converter.documentModified = true; + editor.emit?.('headerFooterPartsChanged', { + addedParts: headerFootersDiff.addedParts.map((part) => part.refId), + removedParts: headerFootersDiff.removedParts.map((part) => part.refId), + modifiedParts: headerFootersDiff.modifiedParts.map((part) => part.refId), + slotChanges: headerFootersDiff.slotChanges.map((slot) => slot.sectionId), + }); + editor.emit?.('headerFooterUpdate', { type: 'replayCompleted' }); + } + + return result; +} + +/** + * Ensures the converter has the mutable collections used by header/footer replay. + * + * @param converter Converter object mutated during replay. + */ +function ensureHeaderFooterCollections(converter: NonNullable): void { + if (!converter.headers) converter.headers = {}; + if (!converter.footers) converter.footers = {}; + if (!converter.headerIds) converter.headerIds = {}; + if (!converter.footerIds) converter.footerIds = {}; + if (!converter.convertedXml) converter.convertedXml = {}; +} + +/** + * Creates a missing header/footer part entry directly in converter state. + * + * @param converter Converter object mutated during replay. + * @param part Target part state that should exist after replay. + */ +function createHeaderFooterPart( + converter: NonNullable, + schema: Schema, + part: HeaderFooterPartState, +): void { + const partCollection = part.kind === 'header' ? converter.headers! : converter.footers!; + partCollection[part.refId] = structuredClone(part.content); + + const variantIds = part.kind === 'header' ? converter.headerIds! : converter.footerIds!; + if (!Array.isArray(variantIds.ids)) { + variantIds.ids = []; + } + if (!variantIds.ids.includes(part.refId)) { + variantIds.ids.push(part.refId); + } + + upsertRelationshipEntry(converter.convertedXml!, part); + ensureXmlPartExists(converter.convertedXml!, part); + syncHeaderFooterPartXml(converter, schema, part.kind, part.refId, part.content); +} + +/** + * Replays one modified part diff into the stored PM JSON and OOXML caches. + * + * @param converter Converter object mutated during replay. + * @param schema Schema used to rebuild the stored PM document. + * @param refId Relationship id of the target part. + * @param kind Whether the target part is a header or footer. + * @param docDiffs Body-style document diffs for the part content. + * @returns `true` when the part was updated. + */ +function applyHeaderFooterPartContent( + converter: NonNullable, + schema: Schema, + refId: string, + kind: HeaderFooterKind, + docDiffs: import('../algorithm/generic-diffing').NodeDiff[], +): boolean { + const collection = kind === 'header' ? converter.headers! : converter.footers!; + const currentJson = collection[refId]; + if (!currentJson || typeof currentJson !== 'object') { + return false; + } + + const state = EditorState.create({ + schema, + doc: schema.nodeFromJSON(currentJson), + }); + const partTr = state.tr; + const replay = replayDocDiffs({ + tr: partTr, + docDiffs, + schema, + }); + if (replay.skipped > 0) { + return false; + } + + const nextJson = partTr.doc.toJSON(); + collection[refId] = nextJson; + syncHeaderFooterPartXml(converter, schema, kind, refId, nextJson); + return true; +} + +/** + * Applies one section slot change to the transaction and converter caches. + * + * @param tr Transaction that should receive the section-property mutation. + * @param editor Editor whose current section projections and converter should be used. + * @param slot Target slot state for one section. + * @returns `true` when the section projection existed and was updated. + */ +function applyHeaderFooterSlotChange( + tr: Transaction, + editor: ReplayHeaderFooterEditor, + slot: HeaderFooterSlotState, +): boolean { + const projectionEditor = { + ...editor, + state: { + ...editor.state, + doc: tr.doc, + }, + }; + const projection = resolveSectionProjections(projectionEditor as never).find( + (entry) => entry.sectionId === slot.sectionId, + ); + if (!projection) { + return false; + } + + const currentSectPr = readTargetSectPr(projectionEditor as never, projection); + const nextSectPr = ensureSectPrElement(currentSectPr); + writeSectPrTitlePage(nextSectPr, slot.titlePg); + + applySlotRefs(nextSectPr, 'header', slot.header); + applySlotRefs(nextSectPr, 'footer', slot.footer); + + if (projection.target.kind === 'paragraph') { + const paragraph = tr.doc.nodeAt(projection.target.pos); + if (!paragraph) { + return false; + } + + const attrs = (paragraph.attrs ?? {}) as Record; + const nextAttrs = { + ...attrs, + paragraphProperties: { + ...((attrs.paragraphProperties ?? {}) as Record), + sectPr: nextSectPr, + }, + pageBreakSource: 'sectPr', + sectionMargins: buildSectionMarginsForAttrs(nextSectPr), + }; + tr.setNodeMarkup(projection.target.pos, undefined, nextAttrs, paragraph.marks); + return true; + } + + tr.setDocAttribute('bodySectPr', nextSectPr); + syncBodySectPrConverterCache(editor.converter!, nextSectPr); + return true; +} + +/** + * Writes the target header/footer refs for one kind into a section property node. + * + * @param sectPr Mutable section property node. + * @param kind Header/footer kind being updated. + * @param refs Target ref mapping for that kind. + */ +function applySlotRefs( + sectPr: ReturnType, + kind: HeaderFooterKind, + refs: Record, +): void { + for (const variant of SLOT_VARIANTS) { + clearSectPrHeaderFooterRef(sectPr, kind, variant); + if (refs[variant]) { + setSectPrHeaderFooterRef(sectPr, kind, variant, refs[variant]!); + } + } +} + +/** + * Builds the paragraph `sectionMargins` attribute value from a sectPr node. + * + * @param sectPr Section property node whose page margins should be read. + * @returns Paragraph attribute payload used by the editor today. + */ +function buildSectionMarginsForAttrs(sectPr: ReturnType): Record { + const pgMar = sectPr.elements?.find((entry) => entry.name === 'w:pgMar'); + return { + top: toInches(pgMar?.attributes?.['w:top']), + right: toInches(pgMar?.attributes?.['w:right']), + bottom: toInches(pgMar?.attributes?.['w:bottom']), + left: toInches(pgMar?.attributes?.['w:left']), + header: toInches(pgMar?.attributes?.['w:header']), + footer: toInches(pgMar?.attributes?.['w:footer']), + }; +} + +/** + * Converts a twips value to inches for section margin attributes. + * + * @param value Raw XML attribute value. + * @returns Inches value, or `null` when the attribute is absent. + */ +function toInches(value: unknown): number | null { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return null; + } + return numeric / 1440; +} + +/** + * Keeps converter body section caches aligned with body sectPr transaction changes. + * + * @param converter Converter object mutated during replay. + * @param sectPr New body section property node. + */ +function syncBodySectPrConverterCache( + converter: NonNullable, + sectPr: ReturnType, +): void { + converter.bodySectPr = cloneXmlElement(sectPr); + + const savedBodyNode = converter.savedTagsToRestore?.find((entry) => entry?.name === 'w:body'); + if (!savedBodyNode || !Array.isArray(savedBodyNode.elements)) { + return; + } + + const preservedChildren = savedBodyNode.elements.filter((entry) => entry?.name !== 'w:sectPr'); + preservedChildren.push(cloneXmlElement(sectPr) as unknown as Record); + savedBodyNode.elements = preservedChildren; +} + +/** + * Removes a header/footer part and all of its derived cache entries. + * + * @param converter Converter object mutated during replay. + * @param part Target part that should be removed after replay. + */ +function deleteHeaderFooterPart( + converter: NonNullable, + part: HeaderFooterPartState, +): void { + const collection = part.kind === 'header' ? converter.headers! : converter.footers!; + delete collection[part.refId]; + + const variantIds = part.kind === 'header' ? converter.headerIds! : converter.footerIds!; + if (Array.isArray(variantIds.ids)) { + variantIds.ids = variantIds.ids.filter((value) => value !== part.refId); + } + for (const key of ['default', 'first', 'even', 'odd']) { + if (variantIds[key] === part.refId) { + variantIds[key] = null; + } + } + + removeRelationshipEntry(converter.convertedXml!, part.refId); + delete converter.convertedXml![part.partPath]; + const partFileName = part.partPath.split('/').pop(); + if (partFileName) { + delete converter.convertedXml![`word/_rels/${partFileName}.rels`]; + } +} + +/** + * Upserts the document relationship entry for one header/footer part. + * + * @param convertedXml Converted XML store mutated during replay. + * @param part Part metadata that should exist after replay. + */ +function upsertRelationshipEntry(convertedXml: Record, part: HeaderFooterPartState): void { + const relsRoot = ensureRelationshipsRoot(convertedXml); + const target = part.partPath.replace(/^word\//, ''); + const type = part.kind === 'header' ? HEADER_RELATIONSHIP_TYPE : FOOTER_RELATIONSHIP_TYPE; + const existing = relsRoot.elements!.find( + (entry) => entry.name === 'Relationship' && entry.attributes?.Id === part.refId, + ); + + if (existing) { + existing.attributes = { + ...(existing.attributes ?? {}), + Id: part.refId, + Type: type, + Target: target, + }; + return; + } + + relsRoot.elements!.push({ + type: 'element', + name: 'Relationship', + attributes: { + Id: part.refId, + Type: type, + Target: target, + }, + elements: [], + }); +} + +/** + * Removes one header/footer relationship entry from `document.xml.rels`. + * + * @param convertedXml Converted XML store mutated during replay. + * @param refId Relationship id to remove. + */ +function removeRelationshipEntry(convertedXml: Record, refId: string): void { + const relsPart = convertedXml['word/_rels/document.xml.rels'] as + | { elements?: Array<{ name?: string; elements?: Array<{ name?: string; attributes?: Record }> }> } + | undefined; + const relsRoot = relsPart?.elements?.find((entry) => entry.name === 'Relationships'); + if (!relsRoot?.elements) { + return; + } + relsRoot.elements = relsRoot.elements.filter( + (entry) => !(entry.name === 'Relationship' && entry.attributes?.Id === refId), + ); +} + +/** + * Ensures the OOXML XML part exists before content is exported into it. + * + * @param convertedXml Converted XML store mutated during replay. + * @param part Target part that should exist. + */ +function ensureXmlPartExists(convertedXml: Record, part: HeaderFooterPartState): void { + if (convertedXml[part.partPath]) { + return; + } + + convertedXml[part.partPath] = { + type: 'element', + name: 'document', + elements: [ + { + type: 'element', + name: part.kind === 'header' ? 'w:hdr' : 'w:ftr', + attributes: getHeaderFooterRootAttributes(convertedXml, part.kind), + elements: [], + }, + ], + }; +} + +/** + * Exports stored PM JSON content back into the OOXML XML part cache. + * + * @param converter Converter object mutated during replay. + * @param kind Header/footer kind being updated. + * @param refId Relationship id of the target part. + * @param content PM JSON document that should be exported. + */ +function syncHeaderFooterPartXml( + converter: NonNullable, + schema: Schema, + kind: HeaderFooterKind, + refId: string, + content: unknown, +): void { + const partPath = findPartPathByRefId(converter.convertedXml!, refId); + if (!partPath) { + return; + } + ensureXmlPartExists(converter.convertedXml!, { + refId, + kind, + partPath, + content: { type: 'doc', content: [] }, + }); + + const exported = converter.exportToXmlJson?.({ + data: content, + editor: { + schema, + getUpdatedJson: () => content, + }, + editorSchema: schema, + isHeaderFooter: true, + comments: [], + commentDefinitions: [], + }); + + const root = converter.convertedXml![partPath] as + | { elements?: Array<{ attributes?: Record; elements?: unknown[] }> } + | undefined; + if (!root?.elements?.[0]) { + return; + } + + root.elements[0].attributes = { + ...getHeaderFooterRootAttributes(converter.convertedXml!, kind), + ...((root.elements[0].attributes ?? {}) as Record), + }; + + if (exported?.result?.elements?.[0]?.elements) { + root.elements[0].elements = exported.result.elements[0].elements; + } +} + +/** + * Builds the namespace attributes needed for a header/footer OOXML root element. + * + * @param convertedXml Converted XML store that may already contain header/footer parts. + * @param kind Header/footer kind being created or repaired. + * @returns Attributes for the `w:hdr` or `w:ftr` root element. + */ +function getHeaderFooterRootAttributes( + convertedXml: Record, + kind: HeaderFooterKind, +): Record { + const existingAttributes = findExistingHeaderFooterRootAttributes(convertedXml, kind); + return { + ...DEFAULT_DOCX_DEFS, + ...(existingAttributes ?? {}), + }; +} + +/** + * Reuses namespace attributes from an existing header/footer part when available. + * + * @param convertedXml Converted XML store that may already contain header/footer parts. + * @param kind Header/footer kind being created or repaired. + * @returns Existing root attributes, or `null` when no matching part exists. + */ +function findExistingHeaderFooterRootAttributes( + convertedXml: Record, + kind: HeaderFooterKind, +): Record | null { + const rootName = kind === 'header' ? 'w:hdr' : 'w:ftr'; + + for (const value of Object.values(convertedXml)) { + const root = (value as { elements?: Array<{ name?: string; attributes?: Record }> } | undefined) + ?.elements?.[0]; + if (root?.name !== rootName || !root.attributes) { + continue; + } + return root.attributes; + } + + return null; +} + +/** + * Locates the OOXML part path for one relationship id. + * + * @param convertedXml Converted XML store that includes `document.xml.rels`. + * @param refId Relationship id to resolve. + * @returns Normalized OOXML part path, or `null` when not found. + */ +function findPartPathByRefId(convertedXml: Record, refId: string): string | null { + const relsPart = convertedXml['word/_rels/document.xml.rels'] as + | { elements?: Array<{ name?: string; elements?: Array<{ name?: string; attributes?: Record }> }> } + | undefined; + const relsRoot = relsPart?.elements?.find((entry) => entry.name === 'Relationships'); + const relationship = relsRoot?.elements?.find( + (entry) => entry.name === 'Relationship' && entry.attributes?.Id === refId, + ); + const target = relationship?.attributes?.Target; + if (!target) { + return null; + } + return target.startsWith('word/') ? target : `word/${target}`; +} + +/** + * Ensures the `Relationships` root exists in `document.xml.rels`. + * + * @param convertedXml Converted XML store mutated during replay. + * @returns Mutable relationships root element. + */ +function ensureRelationshipsRoot(convertedXml: Record): { + elements?: Array<{ name?: string; attributes?: Record; elements?: Array> }>; +} { + if (!convertedXml['word/_rels/document.xml.rels']) { + convertedXml['word/_rels/document.xml.rels'] = { + type: 'element', + name: 'document', + elements: [], + }; + } + + const relsPart = convertedXml['word/_rels/document.xml.rels'] as { + elements?: Array<{ name?: string; attributes?: Record; elements?: Array> }>; + }; + if (!relsPart.elements) { + relsPart.elements = []; + } + + let relsRoot = relsPart.elements.find((entry) => entry.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 = []; + } + return relsRoot; +} diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.ts b/packages/super-editor/src/extensions/diffing/replayDiffs.ts index 5e64872bc7..e9b5e95455 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.ts +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.ts @@ -17,6 +17,7 @@ import { replayDocDiffs } from './replay/replay-doc'; import { replayComments } from './replay/replay-comments'; import { replayStyles } from './replay/replay-styles'; import { replayNumbering } from './replay/replay-numbering'; +import { replayHeaderFooters } from './replay/replay-header-footers'; type ReplayDiffsParams = { tr: import('prosemirror-state').Transaction; @@ -43,10 +44,26 @@ type ReplayDiffsParams = { definitions?: Record; } | null; convertedXml?: Record; + headers?: Record; + footers?: Record; + headerIds?: Record; + footerIds?: Record; + bodySectPr?: Record | null; + savedTagsToRestore?: Array>; documentModified?: boolean; promoteToGuid?: () => string; + exportToXmlJson?: (opts: { + data: unknown; + editor: { schema: import('prosemirror-model').Schema; getUpdatedJson: () => unknown }; + editorSchema: import('prosemirror-model').Schema; + isHeaderFooter: boolean; + comments?: unknown[]; + commentDefinitions?: unknown[]; + isFinalDoc?: boolean; + }) => { result?: { elements?: Array<{ elements?: unknown[] }> } }; } | null; }; + trackedChangesRequested?: boolean; }; /** @@ -60,21 +77,46 @@ type ReplayDiffsParams = { * @param params.editor Editor instance used to emit comment update events. * @returns Summary and transaction containing the replayed steps. */ -export function replayDiffs({ tr, diff, schema, comments = [], editor }: ReplayDiffsParams): ReplayDiffsResult { +export function replayDiffs({ + tr, + diff, + schema, + comments = [], + editor, + trackedChangesRequested = false, +}: ReplayDiffsParams): ReplayDiffsResult { const docReplay = replayDocDiffs({ tr, docDiffs: diff.docDiffs, schema }); const commentsReplay = replayComments({ comments, commentDiffs: diff.commentDiffs, editor }); const stylesReplay = replayStyles({ stylesDiff: diff.stylesDiff, editor }); const numberingReplay = replayNumbering({ numberingDiff: diff.numberingDiff, editor }); + const headerFootersReplay = replayHeaderFooters({ + tr, + headerFootersDiff: diff.headerFootersDiff, + schema, + editor, + trackedChangesRequested, + }); return { tr, - appliedDiffs: docReplay.applied + commentsReplay.applied + stylesReplay.applied + numberingReplay.applied, - skippedDiffs: docReplay.skipped + commentsReplay.skipped + stylesReplay.skipped + numberingReplay.skipped, + appliedDiffs: + docReplay.applied + + commentsReplay.applied + + stylesReplay.applied + + numberingReplay.applied + + headerFootersReplay.applied, + skippedDiffs: + docReplay.skipped + + commentsReplay.skipped + + stylesReplay.skipped + + numberingReplay.skipped + + headerFootersReplay.skipped, warnings: [ ...docReplay.warnings, ...commentsReplay.warnings, ...stylesReplay.warnings, ...numberingReplay.warnings, + ...headerFootersReplay.warnings, ], }; } diff --git a/packages/super-editor/src/extensions/diffing/service/canonicalize.ts b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts index dfba296d48..bb142f44ca 100644 --- a/packages/super-editor/src/extensions/diffing/service/canonicalize.ts +++ b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts @@ -8,6 +8,7 @@ import type { Node as PMNode } from 'prosemirror-model'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import type { CommentInput } from '../algorithm/comment-diffing'; +import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; import { COMMENT_ATTRS_DIFF_IGNORED_KEYS } from '../algorithm/comment-diffing'; import { normalizeDocJSON } from '../algorithm/semantic-normalization'; @@ -17,6 +18,7 @@ export interface CanonicalDiffableState { comments: Record[]; styles: Record | null; numbering: Record | null; + headerFooters: HeaderFooterState | null; } /** @@ -62,12 +64,14 @@ export function buildCanonicalDiffableState( comments: CommentInput[], styles: StylesDocumentProperties | null | undefined, numbering: NumberingProperties | null | undefined, + headerFooters: HeaderFooterState | null | undefined, ): CanonicalDiffableState { return { body: normalizeDocJSON(doc.toJSON() as Record), comments: comments.map(canonicalizeComment), styles: styles ? (styles as unknown as Record) : null, numbering: numbering ? (numbering as unknown as Record) : null, + headerFooters: headerFooters ? structuredClone(headerFooters) : null, }; } diff --git a/packages/super-editor/src/extensions/diffing/service/coverage.ts b/packages/super-editor/src/extensions/diffing/service/coverage.ts index a0deedf4de..ea6c946361 100644 --- a/packages/super-editor/src/extensions/diffing/service/coverage.ts +++ b/packages/super-editor/src/extensions/diffing/service/coverage.ts @@ -1,8 +1,8 @@ /** * Coverage metadata for the diff engine. * - * v1 always covers body, comments, styles, and numbering. - * Header/footer diffing is not supported in v1. + * v1 covers body, comments, styles, and numbering. + * v2 adds header/footer diffing. */ import type { DiffCoverage } from '@superdoc/document-api'; @@ -16,6 +16,15 @@ export const V1_COVERAGE: DiffCoverage = Object.freeze({ headerFooters: false, }); +/** Default v2 coverage — every currently supported component enabled. */ +export const V2_COVERAGE: DiffCoverage = Object.freeze({ + body: true, + comments: true, + styles: true, + numbering: true, + headerFooters: true, +}); + /** * Returns true when two coverage objects are structurally equal. */ diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index 1eaa973d1f..f23c741b0b 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -12,20 +12,23 @@ import type { Transaction } from 'prosemirror-state'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import type { DiffSnapshot, DiffPayload, DiffApplyResult, DiffCoverage } from '@superdoc/document-api'; import type { CommentInput } from '../algorithm/comment-diffing'; +import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; +import { captureHeaderFooterState } from '../algorithm/header-footer-diffing'; import type { DiffResult } from '../computeDiff'; import { computeDiff } from '../computeDiff'; import { replayDiffs, type ReplayDiffsResult } from '../replayDiffs'; import { buildCanonicalDiffableState } from './canonicalize'; import { computeFingerprint } from './fingerprint'; import { buildDiffSummary } from './summary'; -import { V1_COVERAGE, coverageEquals } from './coverage'; +import { V2_COVERAGE, coverageEquals } from './coverage'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const SNAPSHOT_VERSION = 'sd-diff-snapshot/v1' as const; -const PAYLOAD_VERSION = 'sd-diff-payload/v1' as const; +const SNAPSHOT_VERSION_V2 = 'sd-diff-snapshot/v2' as const; +const PAYLOAD_VERSION_V1 = 'sd-diff-payload/v1' as const; +const PAYLOAD_VERSION_V2 = 'sd-diff-payload/v2' as const; const ENGINE_ID = 'super-editor' as const; // --------------------------------------------------------------------------- @@ -38,6 +41,25 @@ export interface DiffServiceEditor { comments?: CommentInput[]; translatedLinkedStyles?: StylesDocumentProperties | null; translatedNumbering?: NumberingProperties | null; + headers?: Record; + footers?: Record; + headerIds?: Record; + footerIds?: Record; + convertedXml?: Record; + numbering?: Record; + bodySectPr?: Record | null; + savedTagsToRestore?: Array>; + headerFooterModified?: boolean; + documentModified?: boolean; + exportToXmlJson?: (opts: { + data: unknown; + editor: { schema: Schema; getUpdatedJson: () => unknown }; + editorSchema: Schema; + isHeaderFooter: boolean; + comments?: unknown[]; + commentDefinitions?: unknown[]; + isFinalDoc?: boolean; + }) => { result?: { elements?: Array<{ elements?: unknown[] }> } }; } | null; emit?: (event: string, payload: unknown) => void; options?: { @@ -62,6 +84,38 @@ function getEditorNumbering(editor: DiffServiceEditor): NumberingProperties | nu return editor.converter?.translatedNumbering ?? null; } +/** + * Captures the current editor's header/footer state for diffing. + * + * @param editor Editor whose converter and section XML should be read. + * @returns Canonical header/footer snapshot for the editor. + */ +function getEditorHeaderFooters(editor: DiffServiceEditor): HeaderFooterState { + return captureHeaderFooterState(editor); +} + +/** + * Builds the canonical fingerprint input for one coverage profile. + * + * @param doc ProseMirror document snapshot. + * @param comments Comment snapshot. + * @param styles Styles snapshot. + * @param numbering Numbering snapshot. + * @param headerFooters Header/footer snapshot. + * @param coverage Coverage flags that decide which components participate. + * @returns Canonical diffable state used for fingerprinting. + */ +function buildCanonicalStateForCoverage( + doc: PMNode, + comments: CommentInput[], + styles: StylesDocumentProperties | null, + numbering: NumberingProperties | null, + headerFooters: HeaderFooterState | null, + coverage: DiffCoverage, +) { + return buildCanonicalDiffableState(doc, comments, styles, numbering, coverage.headerFooters ? headerFooters : null); +} + // --------------------------------------------------------------------------- // Capture // --------------------------------------------------------------------------- @@ -79,15 +133,16 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { const comments = getEditorComments(editor); const styles = getEditorStyles(editor); const numbering = getEditorNumbering(editor); + const headerFooters = getEditorHeaderFooters(editor); - const canonical = buildCanonicalDiffableState(doc, comments, styles, numbering); + const canonical = buildCanonicalStateForCoverage(doc, comments, styles, numbering, headerFooters, V2_COVERAGE); const fingerprint = computeFingerprint(canonical); return { - version: SNAPSHOT_VERSION, + version: SNAPSHOT_VERSION_V2, engine: ENGINE_ID, fingerprint, - coverage: { ...V1_COVERAGE }, + coverage: { ...V2_COVERAGE }, // Deep-clone every slot so the snapshot is immutable. doc.toJSON() // already returns a fresh tree; the rest are live references that would // drift if the editor keeps mutating after capture. @@ -96,6 +151,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { comments: comments as unknown as Record[], styles: styles as unknown as Record | null, numbering: numbering as unknown as Record | null, + headerFooters: headerFooters as unknown as Record, }), }; } @@ -113,7 +169,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif validateSnapshotVersion(targetSnapshot.version); const targetCoverage = targetSnapshot.coverage; - validateCoverageMatch(V1_COVERAGE, targetCoverage); + validateCoverageMatch(V2_COVERAGE, targetCoverage); // Structurally validate payload slots before use — the payload is opaque // and may have been deserialized from external JSON. @@ -122,6 +178,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif const targetComments = (targetSnapshot.payload.comments ?? []) as CommentInput[]; const targetStyles = targetSnapshot.payload.styles as StylesDocumentProperties | null; const targetNumbering = targetSnapshot.payload.numbering as NumberingProperties | null; + const targetHeaderFooters = (targetSnapshot.payload.headerFooters ?? null) as HeaderFooterState | null; const targetDoc = parseDocPayload(editor.state.schema, targetSnapshot.payload.doc); // Re-derive target fingerprint from payload to guard against tampered wrappers. @@ -130,7 +187,14 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif // INVALID_INPUT rather than a raw TypeError. let reDerivedFingerprint: string; try { - const targetCanonical = buildCanonicalDiffableState(targetDoc, targetComments, targetStyles, targetNumbering); + const targetCanonical = buildCanonicalStateForCoverage( + targetDoc, + targetComments, + targetStyles, + targetNumbering, + targetHeaderFooters, + targetCoverage, + ); reDerivedFingerprint = computeFingerprint(targetCanonical); } catch (err) { if (err instanceof DiffServiceError) throw err; @@ -151,7 +215,15 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif const baseComments = getEditorComments(editor); const baseStyles = getEditorStyles(editor); const baseNumbering = getEditorNumbering(editor); - const baseCanonical = buildCanonicalDiffableState(baseDoc, baseComments, baseStyles, baseNumbering); + const baseHeaderFooters = getEditorHeaderFooters(editor); + const baseCanonical = buildCanonicalStateForCoverage( + baseDoc, + baseComments, + baseStyles, + baseNumbering, + baseHeaderFooters, + V2_COVERAGE, + ); const baseFingerprint = computeFingerprint(baseCanonical); // Compute raw diff. Wrap in try-catch so malformed nested comment bodies @@ -169,6 +241,8 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif targetStyles, baseNumbering, targetNumbering, + baseHeaderFooters, + targetHeaderFooters, ); } catch (err) { if (err instanceof DiffServiceError) throw err; @@ -184,14 +258,15 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif commentDiffs: rawDiff.commentDiffs as unknown as Record[], stylesDiff: rawDiff.stylesDiff as unknown as Record | null, numberingDiff: rawDiff.numberingDiff as unknown as Record | null, + headerFootersDiff: rawDiff.headerFootersDiff as unknown as Record | null, }) as Record; return { - version: PAYLOAD_VERSION, + version: PAYLOAD_VERSION_V2, engine: ENGINE_ID, baseFingerprint, targetFingerprint: targetSnapshot.fingerprint, - coverage: { ...V1_COVERAGE }, + coverage: { ...V2_COVERAGE }, summary, // Detach the payload from editor-owned objects before returning it across // the API boundary. Comment diffs can otherwise retain live comment refs. @@ -232,7 +307,15 @@ export function applyDiffPayload( const baseComments = getEditorComments(editor); const baseStyles = getEditorStyles(editor); const baseNumbering = getEditorNumbering(editor); - const baseCanonical = buildCanonicalDiffableState(baseDoc, baseComments, baseStyles, baseNumbering); + const baseHeaderFooters = getEditorHeaderFooters(editor); + const baseCanonical = buildCanonicalStateForCoverage( + baseDoc, + baseComments, + baseStyles, + baseNumbering, + baseHeaderFooters, + diffPayload.coverage, + ); const currentFingerprint = computeFingerprint(baseCanonical); if (currentFingerprint !== diffPayload.baseFingerprint) { @@ -282,6 +365,7 @@ export function applyDiffPayload( schema: editor.state.schema, comments: stagedComments, editor: staging as unknown as Parameters[0]['editor'], + trackedChangesRequested: trackedRequested, }); tr.setMeta('inputType', 'programmatic'); @@ -314,7 +398,7 @@ export function applyDiffPayload( appliedOperations: replayResult.appliedDiffs, baseFingerprint: diffPayload.baseFingerprint, targetFingerprint: diffPayload.targetFingerprint, - coverage: { ...V1_COVERAGE }, + coverage: { ...diffPayload.coverage }, summary: verifiedSummary, diagnostics: replayResult.warnings, }, @@ -337,6 +421,12 @@ const STAGED_CONVERTER_KEYS = [ 'translatedNumbering', 'convertedXml', 'numbering', + 'headers', + 'footers', + 'headerIds', + 'footerIds', + 'bodySectPr', + 'savedTagsToRestore', // promoteToGuid() mutates these on the converter during style/numbering // replay; they must be committed back since Editor.dispatch() only calls // promoteToGuid for body-changing transactions (tr.docChanged). @@ -468,6 +558,7 @@ function parseDiffPayloadContents(payload: Record): DiffResult const commentDiffs = payload.commentDiffs; const stylesDiff = payload.stylesDiff; const numberingDiff = payload.numberingDiff; + const headerFootersDiff = payload.headerFootersDiff; if (!Array.isArray(docDiffs)) { throw new DiffServiceError('INVALID_INPUT', 'Diff payload.docDiffs must be an array.'); @@ -492,6 +583,13 @@ function parseDiffPayloadContents(payload: Record): DiffResult ) { throw new DiffServiceError('INVALID_INPUT', 'Diff payload.numberingDiff must be a plain object or null.'); } + if ( + headerFootersDiff !== null && + headerFootersDiff !== undefined && + (typeof headerFootersDiff !== 'object' || Array.isArray(headerFootersDiff)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.headerFootersDiff must be a plain object or null.'); + } // Deep-clone commentDiffs so replay never holds references to caller-owned // objects. Without this, commentJSON/newCommentJSON pushed into @@ -502,6 +600,7 @@ function parseDiffPayloadContents(payload: Record): DiffResult commentDiffs: structuredClone(commentDiffs) as DiffResult['commentDiffs'], stylesDiff: (stylesDiff ?? null) as DiffResult['stylesDiff'], numberingDiff: (numberingDiff ?? null) as DiffResult['numberingDiff'], + headerFootersDiff: (headerFootersDiff ?? null) as DiffResult['headerFootersDiff'], }; } @@ -563,19 +662,19 @@ function validateEngine(engine: string): void { } function validateSnapshotVersion(version: string): void { - if (version !== SNAPSHOT_VERSION) { + if (version !== SNAPSHOT_VERSION_V2) { throw new DiffServiceError( 'CAPABILITY_UNSUPPORTED', - `Unsupported snapshot version "${version}". Expected "${SNAPSHOT_VERSION}".`, + `Unsupported snapshot version "${version}". Expected "${SNAPSHOT_VERSION_V2}".`, ); } } function validatePayloadVersion(version: string): void { - if (version !== PAYLOAD_VERSION) { + if (version !== PAYLOAD_VERSION_V1 && version !== PAYLOAD_VERSION_V2) { throw new DiffServiceError( 'CAPABILITY_UNSUPPORTED', - `Unsupported diff version "${version}". Expected "${PAYLOAD_VERSION}".`, + `Unsupported diff version "${version}". Expected "${PAYLOAD_VERSION_V1}" or "${PAYLOAD_VERSION_V2}".`, ); } } @@ -606,6 +705,13 @@ function validateSnapshotPayload(payload: Record): void { ) { throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.numbering must be a plain object or null.'); } + if ( + payload.headerFooters !== null && + payload.headerFooters !== undefined && + (typeof payload.headerFooters !== 'object' || Array.isArray(payload.headerFooters)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.headerFooters must be a plain object or null.'); + } } function validateCoverageMatch(base: DiffCoverage, target: DiffCoverage): void { diff --git a/packages/super-editor/src/extensions/diffing/service/index.ts b/packages/super-editor/src/extensions/diffing/service/index.ts index aad2d5eb5a..fe68e4042c 100644 --- a/packages/super-editor/src/extensions/diffing/service/index.ts +++ b/packages/super-editor/src/extensions/diffing/service/index.ts @@ -11,4 +11,4 @@ export { export { buildCanonicalDiffableState, stableStringify, type CanonicalDiffableState } from './canonicalize'; export { computeFingerprint } from './fingerprint'; export { buildDiffSummary } from './summary'; -export { V1_COVERAGE, coverageEquals } from './coverage'; +export { V1_COVERAGE, V2_COVERAGE, coverageEquals } from './coverage'; diff --git a/packages/super-editor/src/extensions/diffing/service/summary.ts b/packages/super-editor/src/extensions/diffing/service/summary.ts index de939d6e91..06b7f548a6 100644 --- a/packages/super-editor/src/extensions/diffing/service/summary.ts +++ b/packages/super-editor/src/extensions/diffing/service/summary.ts @@ -15,12 +15,14 @@ export function buildDiffSummary(diff: DiffResult): DiffSummary { const commentsHasChanges = diff.commentDiffs.length > 0; const stylesHasChanges = diff.stylesDiff !== null; const numberingHasChanges = diff.numberingDiff !== null; + const headerFootersHasChanges = diff.headerFootersDiff !== null; const changedComponents: DiffSummary['changedComponents'] = []; if (bodyHasChanges) changedComponents.push('body'); if (commentsHasChanges) changedComponents.push('comments'); if (stylesHasChanges) changedComponents.push('styles'); if (numberingHasChanges) changedComponents.push('numbering'); + if (headerFootersHasChanges) changedComponents.push('headerFooters'); return { hasChanges: changedComponents.length > 0, @@ -29,5 +31,6 @@ export function buildDiffSummary(diff: DiffResult): DiffSummary { comments: { hasChanges: commentsHasChanges }, styles: { hasChanges: stylesHasChanges }, numbering: { hasChanges: numberingHasChanges }, + headerFooters: { hasChanges: headerFootersHasChanges }, }; } diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index f4c2bddf54..f1efdd375a 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -300,6 +300,7 @@ const handleCompareFile = async (event) => { compareComments, compareTranslatedLinkedStyles, compareTranslatedNumbering, + compareEditor, ); const userToApply = editor.options?.user ?? user; editor.commands.replayDifferences(diff, { user: userToApply, applyTrackedChanges: true }); From 7b88357bf6ab75a192bbd6f1ade9884b86a11607 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 15:13:02 -0300 Subject: [PATCH 02/41] feat(editor): refresh presentation after header/footer diff replay --- .../presentation-editor/PresentationEditor.ts | 17 ++++ .../tests/PresentationEditor.test.ts | 78 +++++++++++++++++++ .../src/document-api-adapters/diff-adapter.ts | 2 +- .../extensions/diffing/headerFooters.test.ts | 28 +++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 4d7b0854ba..f18ae8cd7e 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3316,6 +3316,23 @@ export class PresentationEditor extends EventEmitter { handler: handlePartChanged as (...args: unknown[]) => void, }); + /** + * Refresh header/footer presentation state after converter-backed part mutations + * (e.g. diff replay). Delegates to refreshStructure() which rebuilds the + * descriptor registry and invalidates all cached FlowBlocks. + */ + const handleHeaderFooterPartsChanged = () => { + this.#headerFooterSession?.refreshStructure(); + this.#pendingDocChange = true; + this.#selectionSync.onLayoutStart(); + this.#scheduleRerender(); + }; + this.#editor.on('headerFooterPartsChanged', handleHeaderFooterPartsChanged); + this.#editorListeners.push({ + event: 'headerFooterPartsChanged', + handler: handleHeaderFooterPartsChanged as (...args: unknown[]) => void, + }); + const handleCollaborationReady = (payload: unknown) => { this.emit('collaborationReady', payload); // Setup remote cursor rendering after collaboration is ready diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index de12f1ec56..626537c68f 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -2989,6 +2989,84 @@ describe('PresentationEditor', () => { }); }); + describe('headerFooterPartsChanged event listener', () => { + const buildLayoutResult = () => ({ + layout: { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + numberText: '1', + size: { w: 612, h: 792 }, + fragments: [], + margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, + sectionRefs: { + headerRefs: { default: 'rId-header-default' }, + footerRefs: { default: 'rId-footer-default' }, + }, + }, + ], + }, + measures: [], + headers: [], + footers: [], + }); + + let rafSpy: ReturnType | null = null; + + beforeEach(() => { + rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + }); + + afterEach(() => { + rafSpy?.mockRestore(); + rafSpy = null; + }); + + const waitForLayoutUpdate = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }; + + it('refreshes header/footer presentation state after converter-backed part changes', async () => { + mockIncrementalLayout.mockResolvedValue(buildLayoutResult()); + + const refreshSpy = vi.spyOn(HeaderFooterEditorManager.prototype, 'refresh'); + const invalidateAllSpy = vi.spyOn(HeaderFooterLayoutAdapter.prototype, 'invalidateAll'); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ + (Editor as unknown as MockedEditor).mock.results.length - 1 + ].value; + + await waitForLayoutUpdate(); + + const initialRefreshCalls = refreshSpy.mock.calls.length; + const initialInvalidateAllCalls = invalidateAllSpy.mock.calls.length; + + mockIncrementalLayout.mockClear(); + + const onCalls = mockEditorInstance.on as unknown as Mock; + const partsChangedCall = onCalls.mock.calls.find((call) => call[0] === 'headerFooterPartsChanged'); + expect(partsChangedCall).toBeDefined(); + + const handleHeaderFooterPartsChanged = partsChangedCall![1] as () => void; + handleHeaderFooterPartsChanged(); + + await waitForLayoutUpdate(); + + expect(refreshSpy.mock.calls.length).toBeGreaterThan(initialRefreshCalls); + expect(invalidateAllSpy.mock.calls.length).toBeGreaterThan(initialInvalidateAllCalls); + expect(mockIncrementalLayout).toHaveBeenCalled(); + }); + }); + describe('Input validation', () => { describe('setDocumentMode', () => { it('should throw TypeError for non-string input', () => { diff --git a/packages/super-editor/src/document-api-adapters/diff-adapter.ts b/packages/super-editor/src/document-api-adapters/diff-adapter.ts index 67e1bd39dd..c647eab726 100644 --- a/packages/super-editor/src/document-api-adapters/diff-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/diff-adapter.ts @@ -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); } diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 0e5fb94ab0..2557332672 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -341,4 +341,32 @@ describe('Header/footer diffing', () => { afterEditor.destroy?.(); } }); + + it('keeps body replay tracked when header/footer diffs are present', async () => { + const user = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await createEditor(user); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + afterEditor.dispatch(afterEditor.state.tr.insertText('Updated ', 1)); + seedDefaultHeader(afterEditor, 'Tracked header'); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true })).toBe(true); + expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); + expect(getTrackChanges(beforeEditor.state).length).toBeGreaterThan(0); + expect(captureHeaderFooterState(beforeEditor)).toEqual(captureHeaderFooterState(afterEditor)); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); }); From 54bb09b76705fa9d0681d5aa9f38eb299bece221 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 19 Mar 2026 15:15:07 -0300 Subject: [PATCH 03/41] fix(diff): restore v1 compatibility and header/footer replay state --- .../extensions/diffing/headerFooters.test.ts | 29 +++ .../diffing/replay/replay-header-footers.ts | 32 +++ .../diffing/service/diff-service.test.ts | 192 ++++++++++++++++++ .../diffing/service/diff-service.ts | 25 ++- 4 files changed, 271 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 2557332672..0b4e462be6 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -369,4 +369,33 @@ describe('Header/footer diffing', () => { afterEditor.destroy?.(); } }); + + it('updates titlePg cache after replay changes first-page header settings', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedDefaultHeader(beforeEditor, 'Default header'); + seedDefaultHeader(afterEditor, 'Default header'); + setBodySection(afterEditor, { titlePg: true, headerDefault: 'rIdHeader1' }); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(beforeEditor.converter?.headerIds?.titlePg).not.toBe(true); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + + expect(beforeEditor.converter?.headerIds?.titlePg).toBe(true); + expect(beforeEditor.converter?.footerIds?.titlePg).toBe(true); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); }); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 14b7484c6f..59e3260d85 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -126,6 +126,10 @@ export function replayHeaderFooters({ ); } + if (headerFootersDiff.slotChanges.length > 0) { + syncTitlePageCache(tr, editor); + } + for (const part of headerFootersDiff.removedParts) { deleteHeaderFooterPart(editor.converter, part); result.applied += 1; @@ -363,6 +367,34 @@ function syncBodySectPrConverterCache( savedBodyNode.elements = preservedChildren; } +/** + * Recomputes the converter's global title-page cache from the updated document. + * + * @param tr Transaction containing the latest section-property state. + * @param editor Editor whose converter caches should be refreshed. + */ +function syncTitlePageCache(tr: Transaction, editor: ReplayHeaderFooterEditor): void { + if (!editor.converter) { + return; + } + + if (!editor.converter.headerIds) editor.converter.headerIds = {}; + if (!editor.converter.footerIds) editor.converter.footerIds = {}; + + const projectionEditor = { + ...editor, + state: { + ...editor.state, + doc: tr.doc, + }, + }; + const hasTitlePage = resolveSectionProjections(projectionEditor as never).some( + (entry) => entry.range.titlePg === true, + ); + editor.converter.headerIds.titlePg = hasTitlePage; + editor.converter.footerIds.titlePg = hasTitlePage; +} + /** * Removes a header/footer part and all of its derived cache entries. * diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index aac31b7e30..066ba96f47 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -5,7 +5,11 @@ import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import type { CommentInput } from '../algorithm/comment-diffing.ts'; +import { captureHeaderFooterState } from '../algorithm/header-footer-diffing.ts'; import { applyDiffPayload, captureSnapshot, compareToSnapshot } from './index.ts'; +import { buildCanonicalDiffableState } from './canonicalize.ts'; +import { computeFingerprint } from './fingerprint.ts'; +import { V1_COVERAGE } from './coverage.ts'; const TEST_USER = { name: 'Test User', email: 'test@example.com' }; @@ -36,6 +40,141 @@ function setEditorComments(editor: Editor, comments: CommentInput[]): void { editor.converter.comments = comments; } +function createHeaderFooterDoc(editor: Editor, text: string): Record { + 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; +} + +function seedPart( + editor: Editor, + params: { kind: 'header' | 'footer'; refId: string; partPath: string; text: string }, +): void { + const { kind, refId, partPath, text } = params; + const converter = editor.converter!; + const collection = kind === 'header' ? (converter.headers ??= {}) : (converter.footers ??= {}); + collection[refId] = createHeaderFooterDoc(editor, text); + + const variantIds = kind === 'header' ? (converter.headerIds ??= {}) : (converter.footerIds ??= {}); + if (!Array.isArray(variantIds.ids)) { + variantIds.ids = []; + } + if (!variantIds.ids.includes(refId)) { + variantIds.ids.push(refId); + } + + const relsPart = (converter.convertedXml!['word/_rels/document.xml.rels'] ??= { + type: 'element', + name: 'document', + elements: [], + }) as { elements?: Array<{ name?: string; attributes?: Record; elements?: unknown[] }> }; + if (!relsPart.elements) { + relsPart.elements = []; + } + let relsRoot = relsPart.elements.find((entry) => entry.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 = []; + } + + const existing = relsRoot.elements.find( + (entry) => entry?.name === 'Relationship' && entry.attributes?.Id === refId, + ) as { attributes?: Record } | undefined; + const attributes = { + Id: refId, + Type: + kind === 'header' + ? 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header' + : 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', + Target: partPath.replace(/^word\//, ''), + }; + + if (existing) { + existing.attributes = attributes; + } else { + relsRoot.elements.push({ + name: 'Relationship', + attributes, + elements: [], + }); + } +} + +function setBodySection( + editor: Editor, + params: { + titlePg?: boolean; + headerDefault?: string | null; + footerDefault?: string | null; + }, +): void { + const elements: Array> = [ + { + 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', + }, + }, + ]; + + if (params.titlePg) { + elements.push({ type: 'element', name: 'w:titlePg', elements: [] }); + } + if (params.headerDefault) { + elements.push({ + type: 'element', + name: 'w:headerReference', + attributes: { 'w:type': 'default', 'r:id': params.headerDefault }, + elements: [], + }); + } + if (params.footerDefault) { + elements.push({ + type: 'element', + name: 'w:footerReference', + attributes: { 'w:type': 'default', 'r:id': params.footerDefault }, + elements: [], + }); + } + + editor.converter!.bodySectPr = { + type: 'element', + name: 'w:sectPr', + elements, + }; +} + +function seedDefaultHeader(editor: Editor, text: string): void { + seedPart(editor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header1.xml', + text, + }); + setBodySection(editor, { headerDefault: 'rIdHeader1' }); +} + async function openBlankDocxWithText(text: string): Promise { const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), { isHeadless: true, @@ -152,6 +291,59 @@ describe('diff-service tracked apply', () => { } }); + it('accepts legacy v1 snapshots during compare', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Updated document.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const legacySnapshot = structuredClone(snapshot); + legacySnapshot.version = 'sd-diff-snapshot/v1'; + legacySnapshot.coverage = { ...V1_COVERAGE }; + delete (legacySnapshot.payload as Record).headerFooters; + legacySnapshot.fingerprint = computeFingerprint( + buildCanonicalDiffableState( + targetEditor.state.doc, + targetEditor.converter?.comments ?? [], + targetEditor.converter?.translatedLinkedStyles ?? null, + targetEditor.converter?.translatedNumbering ?? null, + null, + ), + ); + + const diff = compareToSnapshot(baseEditor, legacySnapshot); + + expect(diff.version).toBe('sd-diff-payload/v1'); + expect(diff.summary.body.hasChanges).toBe(true); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('commits headerFooterModified after applyDiffPayload replays header changes', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document.'); + + try { + setBodySection(baseEditor, {}); + seedDefaultHeader(targetEditor, 'Applied header'); + baseEditor.converter!.headerFooterModified = false; + + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const { tr } = applyDiffPayload(baseEditor, diff, { changeMode: 'direct' }); + + baseEditor.dispatch(tr); + + expect(baseEditor.converter?.headerFooterModified).toBe(true); + expect(captureHeaderFooterState(baseEditor)).toEqual(captureHeaderFooterState(targetEditor)); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + it('returns comment diffs detached from base comments and target snapshot payloads', async () => { const baseEditor = await openBlankDocxWithText('Base document.'); const targetEditor = await openBlankDocxWithText('Base document.'); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index f23c741b0b..cbc86b08ab 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -20,7 +20,7 @@ import { replayDiffs, type ReplayDiffsResult } from '../replayDiffs'; import { buildCanonicalDiffableState } from './canonicalize'; import { computeFingerprint } from './fingerprint'; import { buildDiffSummary } from './summary'; -import { V2_COVERAGE, coverageEquals } from './coverage'; +import { V1_COVERAGE, V2_COVERAGE, coverageEquals } from './coverage'; // --------------------------------------------------------------------------- // Constants @@ -168,8 +168,9 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif validateEngine(targetSnapshot.engine); validateSnapshotVersion(targetSnapshot.version); + const expectedCoverage = getCoverageForSnapshotVersion(targetSnapshot.version); const targetCoverage = targetSnapshot.coverage; - validateCoverageMatch(V2_COVERAGE, targetCoverage); + validateCoverageMatch(expectedCoverage, targetCoverage); // Structurally validate payload slots before use — the payload is opaque // and may have been deserialized from external JSON. @@ -222,7 +223,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif baseStyles, baseNumbering, baseHeaderFooters, - V2_COVERAGE, + targetCoverage, ); const baseFingerprint = computeFingerprint(baseCanonical); @@ -262,11 +263,11 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif }) as Record; return { - version: PAYLOAD_VERSION_V2, + version: getPayloadVersionForCoverage(targetCoverage), engine: ENGINE_ID, baseFingerprint, targetFingerprint: targetSnapshot.fingerprint, - coverage: { ...V2_COVERAGE }, + coverage: { ...targetCoverage }, summary, // Detach the payload from editor-owned objects before returning it across // the API boundary. Comment diffs can otherwise retain live comment refs. @@ -471,6 +472,7 @@ function createStagingEditor( } // Replay also sets documentModified (primitive) — seed from current value + cloned.headerFooterModified = raw.headerFooterModified; cloned.documentModified = raw.documentModified; // Point cloned converter's comments at the staged array cloned.comments = stagedComments; @@ -496,6 +498,7 @@ function createStagingEditor( for (const key of STAGED_CONVERTER_KEYS) { realRaw[key] = stagedRaw[key]; } + realRaw.headerFooterModified = stagedRaw.headerFooterModified; realRaw.documentModified = stagedRaw.documentModified; } @@ -662,10 +665,10 @@ function validateEngine(engine: string): void { } function validateSnapshotVersion(version: string): void { - if (version !== SNAPSHOT_VERSION_V2) { + if (version !== 'sd-diff-snapshot/v1' && version !== SNAPSHOT_VERSION_V2) { throw new DiffServiceError( 'CAPABILITY_UNSUPPORTED', - `Unsupported snapshot version "${version}". Expected "${SNAPSHOT_VERSION_V2}".`, + `Unsupported snapshot version "${version}". Expected "sd-diff-snapshot/v1" or "${SNAPSHOT_VERSION_V2}".`, ); } } @@ -723,6 +726,14 @@ function validateCoverageMatch(base: DiffCoverage, target: DiffCoverage): void { } } +function getCoverageForSnapshotVersion(version: DiffSnapshot['version']): DiffCoverage { + return version === 'sd-diff-snapshot/v1' ? V1_COVERAGE : V2_COVERAGE; +} + +function getPayloadVersionForCoverage(coverage: DiffCoverage): DiffPayload['version'] { + return coverage.headerFooters ? PAYLOAD_VERSION_V2 : PAYLOAD_VERSION_V1; +} + // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- From ae0bd768c7c5fc026a25dd8c0386cd2f0cd647f6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:10:45 -0300 Subject: [PATCH 04/41] fix(diff): gate header/footer capture on target coverage in compareToSnapshot --- .../diffing/service/diff-service.test.ts | 35 +++++++++++++++++++ .../diffing/service/diff-service.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 066ba96f47..0d14187025 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -321,6 +321,41 @@ describe('diff-service tracked apply', () => { } }); + it('does not produce header/footer removal diffs when comparing against a v1 snapshot', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Updated document.'); + + try { + // Base editor has a real header — v1 snapshot does not cover headers, + // so the diff must NOT treat existing headers as "removed". + seedDefaultHeader(baseEditor, 'Existing header'); + + const snapshot = captureSnapshot(targetEditor); + const legacySnapshot = structuredClone(snapshot); + legacySnapshot.version = 'sd-diff-snapshot/v1'; + legacySnapshot.coverage = { ...V1_COVERAGE }; + delete (legacySnapshot.payload as Record).headerFooters; + legacySnapshot.fingerprint = computeFingerprint( + buildCanonicalDiffableState( + targetEditor.state.doc, + targetEditor.converter?.comments ?? [], + targetEditor.converter?.translatedLinkedStyles ?? null, + targetEditor.converter?.translatedNumbering ?? null, + null, + ), + ); + + const diff = compareToSnapshot(baseEditor, legacySnapshot); + + expect(diff.version).toBe('sd-diff-payload/v1'); + expect(diff.payload.headerFootersDiff).toBeNull(); + expect(diff.summary.headerFooters.hasChanges).toBe(false); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + it('commits headerFooterModified after applyDiffPayload replays header changes', async () => { const baseEditor = await openBlankDocxWithText('Base document.'); const targetEditor = await openBlankDocxWithText('Base document.'); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index cbc86b08ab..3bc4241247 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -216,7 +216,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif const baseComments = getEditorComments(editor); const baseStyles = getEditorStyles(editor); const baseNumbering = getEditorNumbering(editor); - const baseHeaderFooters = getEditorHeaderFooters(editor); + const baseHeaderFooters = targetCoverage.headerFooters ? getEditorHeaderFooters(editor) : null; const baseCanonical = buildCanonicalStateForCoverage( baseDoc, baseComments, From 91eb35f9889f86cd95247d632b7ee820f308db0b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:14:33 -0300 Subject: [PATCH 05/41] fix(diff): reuse normalizePartPath in replay to fix denormalized relationship targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../diffing/algorithm/header-footer-diffing.ts | 2 +- .../diffing/replay/replay-header-footers.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts index b40c842265..b463cb2d77 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -238,7 +238,7 @@ function readHeaderFooterPartPaths(editor: HeaderFooterEditor): Map, refId: strin if (!target) { return null; } - return target.startsWith('word/') ? target : `word/${target}`; + return normalizePartPath(target); } /** From 7b63cc57ff42d653cb44a702c8438201b9a9c9e2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:20:45 -0300 Subject: [PATCH 06/41] 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. --- .../src/extensions/diffing/replay/replay-header-footers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index f42fb640b6..c1ac295988 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -115,10 +115,12 @@ export function replayHeaderFooters({ result.warnings.push(`Header/footer replay skipped for "${part.refId}": stored part content was not found.`); } + let slotChangesApplied = 0; for (const slot of headerFootersDiff.slotChanges) { const applied = applyHeaderFooterSlotChange(tr, editor, slot); if (applied) { result.applied += 1; + slotChangesApplied += 1; continue; } result.skipped += 1; @@ -127,7 +129,7 @@ export function replayHeaderFooters({ ); } - if (headerFootersDiff.slotChanges.length > 0) { + if (slotChangesApplied > 0) { syncTitlePageCache(tr, editor); } From 752441a23fcbc7e737895d08de5295ae320759ad Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:24:02 -0300 Subject: [PATCH 07/41] 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. --- .../src/extensions/diffing/algorithm/header-footer-diffing.ts | 2 +- .../src/extensions/diffing/replay/replay-header-footers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts index b463cb2d77..67a4804817 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -70,7 +70,7 @@ type HeaderFooterEditor = { } | null; }; -const SLOT_VARIANTS: HeaderFooterVariant[] = ['default', 'first', 'even']; +export const SLOT_VARIANTS: HeaderFooterVariant[] = ['default', 'first', 'even']; const PART_KINDS: HeaderFooterKind[] = ['header', 'footer']; /** diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index c1ac295988..725911fd9e 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -4,6 +4,7 @@ import { replayDocDiffs } from './replay-doc'; import { ReplayResult } from './replay-types'; import { normalizePartPath, + SLOT_VARIANTS, type HeaderFootersDiff, type HeaderFooterKind, type HeaderFooterPartState, @@ -48,7 +49,6 @@ type ReplayHeaderFooterEditor = { } | null; }; -const SLOT_VARIANTS: HeaderFooterVariant[] = ['default', 'first', 'even']; const HEADER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; const FOOTER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; From 4461af7bb32a5260b67b57e0ff3d30cbec967736 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:34:50 -0300 Subject: [PATCH 08/41] 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. --- .../diffing/replay/replay-header-footers.ts | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 725911fd9e..42f0f93030 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -18,6 +18,7 @@ import { cloneXmlElement, clearSectPrHeaderFooterRef, setSectPrHeaderFooterRef, + readSectPrMargins, writeSectPrTitlePage, } from '../../../document-api-adapters/helpers/sections-xml.js'; import { DEFAULT_DOCX_DEFS } from '../../../core/super-converter/exporter-docx-defs.js'; @@ -285,7 +286,7 @@ function applyHeaderFooterSlotChange( sectPr: nextSectPr, }, pageBreakSource: 'sectPr', - sectionMargins: buildSectionMarginsForAttrs(nextSectPr), + sectionMargins: readSectPrMargins(nextSectPr), }; tr.setNodeMarkup(projection.target.pos, undefined, nextAttrs, paragraph.marks); return true; @@ -316,38 +317,6 @@ function applySlotRefs( } } -/** - * Builds the paragraph `sectionMargins` attribute value from a sectPr node. - * - * @param sectPr Section property node whose page margins should be read. - * @returns Paragraph attribute payload used by the editor today. - */ -function buildSectionMarginsForAttrs(sectPr: ReturnType): Record { - const pgMar = sectPr.elements?.find((entry) => entry.name === 'w:pgMar'); - return { - top: toInches(pgMar?.attributes?.['w:top']), - right: toInches(pgMar?.attributes?.['w:right']), - bottom: toInches(pgMar?.attributes?.['w:bottom']), - left: toInches(pgMar?.attributes?.['w:left']), - header: toInches(pgMar?.attributes?.['w:header']), - footer: toInches(pgMar?.attributes?.['w:footer']), - }; -} - -/** - * Converts a twips value to inches for section margin attributes. - * - * @param value Raw XML attribute value. - * @returns Inches value, or `null` when the attribute is absent. - */ -function toInches(value: unknown): number | null { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return null; - } - return numeric / 1440; -} - /** * Keeps converter body section caches aligned with body sectPr transaction changes. * From bc228fa28c41dbc9a2fd85129083b6c37d36d8dd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 11:51:48 -0300 Subject: [PATCH 09/41] 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. --- .../presentation-editor/PresentationEditor.ts | 17 ---- .../tests/PresentationEditor.test.ts | 78 ------------------- .../algorithm/header-footer-diffing.ts | 2 + .../extensions/diffing/headerFooters.test.ts | 7 +- .../diffing/replay/replay-header-footers.ts | 32 ++++++-- 5 files changed, 33 insertions(+), 103 deletions(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index f18ae8cd7e..4d7b0854ba 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3316,23 +3316,6 @@ export class PresentationEditor extends EventEmitter { handler: handlePartChanged as (...args: unknown[]) => void, }); - /** - * Refresh header/footer presentation state after converter-backed part mutations - * (e.g. diff replay). Delegates to refreshStructure() which rebuilds the - * descriptor registry and invalidates all cached FlowBlocks. - */ - const handleHeaderFooterPartsChanged = () => { - this.#headerFooterSession?.refreshStructure(); - this.#pendingDocChange = true; - this.#selectionSync.onLayoutStart(); - this.#scheduleRerender(); - }; - this.#editor.on('headerFooterPartsChanged', handleHeaderFooterPartsChanged); - this.#editorListeners.push({ - event: 'headerFooterPartsChanged', - handler: handleHeaderFooterPartsChanged as (...args: unknown[]) => void, - }); - const handleCollaborationReady = (payload: unknown) => { this.emit('collaborationReady', payload); // Setup remote cursor rendering after collaboration is ready diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index 626537c68f..de12f1ec56 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -2989,84 +2989,6 @@ describe('PresentationEditor', () => { }); }); - describe('headerFooterPartsChanged event listener', () => { - const buildLayoutResult = () => ({ - layout: { - pageSize: { w: 612, h: 792 }, - pages: [ - { - number: 1, - numberText: '1', - size: { w: 612, h: 792 }, - fragments: [], - margins: { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 }, - sectionRefs: { - headerRefs: { default: 'rId-header-default' }, - footerRefs: { default: 'rId-footer-default' }, - }, - }, - ], - }, - measures: [], - headers: [], - footers: [], - }); - - let rafSpy: ReturnType | null = null; - - beforeEach(() => { - rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - cb(0); - return 1; - }); - }); - - afterEach(() => { - rafSpy?.mockRestore(); - rafSpy = null; - }); - - const waitForLayoutUpdate = async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }; - - it('refreshes header/footer presentation state after converter-backed part changes', async () => { - mockIncrementalLayout.mockResolvedValue(buildLayoutResult()); - - const refreshSpy = vi.spyOn(HeaderFooterEditorManager.prototype, 'refresh'); - const invalidateAllSpy = vi.spyOn(HeaderFooterLayoutAdapter.prototype, 'invalidateAll'); - - editor = new PresentationEditor({ - element: container, - documentId: 'test-doc', - }); - - const mockEditorInstance = (Editor as unknown as MockedEditor).mock.results[ - (Editor as unknown as MockedEditor).mock.results.length - 1 - ].value; - - await waitForLayoutUpdate(); - - const initialRefreshCalls = refreshSpy.mock.calls.length; - const initialInvalidateAllCalls = invalidateAllSpy.mock.calls.length; - - mockIncrementalLayout.mockClear(); - - const onCalls = mockEditorInstance.on as unknown as Mock; - const partsChangedCall = onCalls.mock.calls.find((call) => call[0] === 'headerFooterPartsChanged'); - expect(partsChangedCall).toBeDefined(); - - const handleHeaderFooterPartsChanged = partsChangedCall![1] as () => void; - handleHeaderFooterPartsChanged(); - - await waitForLayoutUpdate(); - - expect(refreshSpy.mock.calls.length).toBeGreaterThan(initialRefreshCalls); - expect(invalidateAllSpy.mock.calls.length).toBeGreaterThan(initialInvalidateAllCalls); - expect(mockIncrementalLayout).toHaveBeenCalled(); - }); - }); - describe('Input validation', () => { describe('setDocumentMode', () => { it('should throw TypeError for non-string input', () => { diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts index 67a4804817..732c3d4534 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -48,6 +48,7 @@ export interface HeaderFooterState { export interface ModifiedHeaderFooterPart { refId: string; kind: HeaderFooterKind; + partPath: string; docDiffs: NodeDiff[]; } @@ -127,6 +128,7 @@ export function diffHeaderFooters( modifiedParts.push({ refId: nextPart.refId, kind: nextPart.kind, + partPath: nextPart.partPath, docDiffs, }); } diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 0b4e462be6..c8f32e6b0f 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -250,9 +250,12 @@ describe('Header/footer diffing', () => { expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); expect(emitSpy).toHaveBeenCalledWith( - 'headerFooterPartsChanged', + 'partChanged', expect.objectContaining({ - addedParts: ['rIdHeader1'], + source: 'diff-replay', + parts: expect.arrayContaining([ + expect.objectContaining({ partId: 'word/header1.xml', sectionId: 'rIdHeader1', operation: 'create' }), + ]), }), ); } finally { diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 42f0f93030..c6a18c3370 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -143,12 +143,32 @@ export function replayHeaderFooters({ tr.setMeta('forceUpdatePagination', true); editor.converter.headerFooterModified = true; editor.converter.documentModified = true; - editor.emit?.('headerFooterPartsChanged', { - addedParts: headerFootersDiff.addedParts.map((part) => part.refId), - removedParts: headerFootersDiff.removedParts.map((part) => part.refId), - modifiedParts: headerFootersDiff.modifiedParts.map((part) => part.refId), - slotChanges: headerFootersDiff.slotChanges.map((slot) => slot.sectionId), - }); + const changedParts: Array<{ + partId: string; + sectionId?: string; + operation: 'mutate' | 'create' | 'delete'; + changedPaths: string[]; + }> = []; + + if ( + headerFootersDiff.addedParts.length > 0 || + headerFootersDiff.removedParts.length > 0 || + headerFootersDiff.slotChanges.length > 0 + ) { + changedParts.push({ partId: 'word/_rels/document.xml.rels', operation: 'mutate', changedPaths: [] }); + } + + for (const part of headerFootersDiff.addedParts) { + changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'create', changedPaths: [] }); + } + for (const part of headerFootersDiff.modifiedParts) { + changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'mutate', changedPaths: [] }); + } + for (const part of headerFootersDiff.removedParts) { + changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'delete', changedPaths: [] }); + } + + editor.emit?.('partChanged', { parts: changedParts, source: 'diff-replay' }); editor.emit?.('headerFooterUpdate', { type: 'replayCompleted' }); } From 325a4b2c713241a569c9be6f9308f154446ea398 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 13:19:07 -0300 Subject: [PATCH 10/41] feat(diff): thread partsDiff through diff/replay pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/document-api/src/contract/schemas.ts | 5 +- packages/document-api/src/diff/diff.types.ts | 3 +- .../diffing/algorithm/parts-diffing.ts | 32 ++++++++++++ .../src/extensions/diffing/computeDiff.ts | 4 ++ .../extensions/diffing/replay/replay-parts.ts | 39 +++++++++++++++ .../src/extensions/diffing/replayDiffs.ts | 12 ++++- .../diffing/service/diff-service.ts | 50 ++++++++++++++++++- .../src/extensions/diffing/service/summary.ts | 3 ++ 8 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts create mode 100644 packages/super-editor/src/extensions/diffing/replay/replay-parts.ts diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 48b949b665..cbd4ccd95d 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2787,15 +2787,16 @@ const diffSummarySchema: JsonSchema = objectSchema( hasChanges: { type: 'boolean' }, changedComponents: { type: 'array', - items: { type: 'string', enum: ['body', 'comments', 'styles', 'numbering', 'headerFooters'] }, + 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', 'headerFooters'], + ['hasChanges', 'changedComponents', 'body', 'comments', 'styles', 'numbering', 'headerFooters', 'parts'], ); const diffSnapshotSchema: JsonSchema = objectSchema( diff --git a/packages/document-api/src/diff/diff.types.ts b/packages/document-api/src/diff/diff.types.ts index 8d8debc0d9..e9f5e39780 100644 --- a/packages/document-api/src/diff/diff.types.ts +++ b/packages/document-api/src/diff/diff.types.ts @@ -47,12 +47,13 @@ export interface DiffSnapshot { /** Coarse change summary for a diff payload. */ export interface DiffSummary { hasChanges: boolean; - changedComponents: Array<'body' | 'comments' | 'styles' | 'numbering' | 'headerFooters'>; + 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. */ diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts new file mode 100644 index 0000000000..7776e60b44 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -0,0 +1,32 @@ +/** + * Generic part-level diff payload. + * + * This is intentionally coarse-grained: callers can upsert or delete + * normalized parts without requiring OOXML tree diffs. + */ +export interface PartsDiff { + upserts: Record< + string, + | { + kind: 'xml'; + content: Record; + } + | { + kind: 'binary'; + encoding: 'base64'; + content: string; + } + >; + deletes: string[]; +} + +/** + * Placeholder parts diff computation. + * + * The first implementation slice only threads partsDiff through the + * diff/replay pipeline so later changes can populate it without reshaping + * the service contract again. + */ +export function diffParts(): PartsDiff | null { + return null; +} diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 317138c855..89eaba5cc0 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -2,6 +2,7 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import { diffComments, type CommentInput, type CommentDiff } from './algorithm/comment-diffing'; import { diffHeaderFooters, type HeaderFooterState, type HeaderFootersDiff } from './algorithm/header-footer-diffing'; +import { diffParts, type PartsDiff } from './algorithm/parts-diffing'; import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing'; import { diffStyles, type StylesDiff } from './algorithm/styles-diffing'; import { diffNumbering, type NumberingDiff } from './algorithm/numbering-diffing'; @@ -20,6 +21,8 @@ export interface DiffResult { numberingDiff: NumberingDiff | null; /** Diffs computed from header/footer parts and section slot refs. */ headerFootersDiff: HeaderFootersDiff | null; + /** Diffs computed from OOXML parts and media assets. */ + partsDiff: PartsDiff | null; } /** @@ -64,5 +67,6 @@ export function computeDiff( stylesDiff: diffStyles(oldStyles, newStyles), numberingDiff: diffNumbering(oldNumbering, newNumbering), headerFootersDiff: diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema), + partsDiff: diffParts(), }; } diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts new file mode 100644 index 0000000000..5796797585 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -0,0 +1,39 @@ +import { ReplayResult } from './replay-types'; +import type { PartsDiff } from '../algorithm/parts-diffing'; + +type ReplayPartsEditor = { + options?: { + mediaFiles?: Record; + }; + storage?: { + image?: { + media?: Record; + }; + }; + converter?: { + convertedXml?: Record; + } | null; +}; + +/** + * Placeholder parts replay. + * + * A later change will make this authoritative for parts reconstruction. + * For now it remains a validated no-op so the rest of the diff pipeline can + * adopt the new field safely. + */ +export function replayPartsDiff({ + partsDiff, + editor, +}: { + partsDiff: PartsDiff | null; + editor?: ReplayPartsEditor; +}): ReplayResult { + void partsDiff; + void editor; + return { + applied: 0, + skipped: 0, + warnings: [], + }; +} diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.ts b/packages/super-editor/src/extensions/diffing/replayDiffs.ts index e9b5e95455..dbf52dab41 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.ts +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.ts @@ -18,6 +18,7 @@ import { replayComments } from './replay/replay-comments'; import { replayStyles } from './replay/replay-styles'; import { replayNumbering } from './replay/replay-numbering'; import { replayHeaderFooters } from './replay/replay-header-footers'; +import { replayPartsDiff } from './replay/replay-parts'; type ReplayDiffsParams = { tr: import('prosemirror-state').Transaction; @@ -96,6 +97,10 @@ export function replayDiffs({ editor, trackedChangesRequested, }); + const partsReplay = replayPartsDiff({ + partsDiff: diff.partsDiff, + editor, + }); return { tr, @@ -104,19 +109,22 @@ export function replayDiffs({ commentsReplay.applied + stylesReplay.applied + numberingReplay.applied + - headerFootersReplay.applied, + headerFootersReplay.applied + + partsReplay.applied, skippedDiffs: docReplay.skipped + commentsReplay.skipped + stylesReplay.skipped + numberingReplay.skipped + - headerFootersReplay.skipped, + headerFootersReplay.skipped + + partsReplay.skipped, warnings: [ ...docReplay.warnings, ...commentsReplay.warnings, ...stylesReplay.warnings, ...numberingReplay.warnings, ...headerFootersReplay.warnings, + ...partsReplay.warnings, ], }; } diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index 3bc4241247..a55cc71b90 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -13,6 +13,7 @@ import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/st import type { DiffSnapshot, DiffPayload, DiffApplyResult, DiffCoverage } from '@superdoc/document-api'; import type { CommentInput } from '../algorithm/comment-diffing'; import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; +import type { PartsDiff } from '../algorithm/parts-diffing'; import { captureHeaderFooterState } from '../algorithm/header-footer-diffing'; import type { DiffResult } from '../computeDiff'; import { computeDiff } from '../computeDiff'; @@ -65,6 +66,12 @@ export interface DiffServiceEditor { options?: { documentId?: string | null; user?: unknown; + mediaFiles?: Record; + }; + storage?: { + image?: { + media?: Record; + }; }; } @@ -152,6 +159,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { styles: styles as unknown as Record | null, numbering: numbering as unknown as Record | null, headerFooters: headerFooters as unknown as Record, + partsState: null, }), }; } @@ -260,6 +268,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif stylesDiff: rawDiff.stylesDiff as unknown as Record | null, numberingDiff: rawDiff.numberingDiff as unknown as Record | null, headerFootersDiff: rawDiff.headerFootersDiff as unknown as Record | null, + partsDiff: rawDiff.partsDiff as unknown as Record | null, }) as Record; return { @@ -455,6 +464,25 @@ function createStagingEditor( ): { staging: DiffServiceEditor; stagedComments: CommentInput[]; commit: () => void } { const pendingEvents: Array<[string, unknown]> = []; const stagedComments = comments.map((c) => ({ ...c })); + const stagedOptions: DiffServiceEditor['options'] = editor.options + ? { + ...editor.options, + mediaFiles: editor.options.mediaFiles ? structuredClone(editor.options.mediaFiles) : editor.options.mediaFiles, + } + : editor.options; + const stagedStorage: DiffServiceEditor['storage'] = editor.storage + ? { + ...editor.storage, + image: editor.storage.image + ? { + ...editor.storage.image, + media: editor.storage.image.media + ? structuredClone(editor.storage.image.media) + : editor.storage.image.media, + } + : editor.storage.image, + } + : editor.storage; // Build a staging converter that inherits non-mutable properties from // the real converter via Object.create, then deep-clones only the @@ -485,7 +513,8 @@ function createStagingEditor( emit: (event: string, payload: unknown) => { pendingEvents.push([event, payload]); }, - options: editor.options, + options: stagedOptions, + storage: stagedStorage, converter: stagedConverter, }; @@ -502,6 +531,13 @@ function createStagingEditor( realRaw.documentModified = stagedRaw.documentModified; } + if (editor.options && stagedOptions && 'mediaFiles' in stagedOptions) { + editor.options.mediaFiles = stagedOptions.mediaFiles; + } + if (editor.storage?.image && stagedStorage?.image) { + editor.storage.image.media = stagedStorage.image.media; + } + // Apply comment mutations to the real array. Deep-clone each entry // so the editor owns its comment objects outright — without this, // commentJSON / newCommentJSON references from the caller's diff @@ -562,6 +598,7 @@ function parseDiffPayloadContents(payload: Record): DiffResult const stylesDiff = payload.stylesDiff; const numberingDiff = payload.numberingDiff; const headerFootersDiff = payload.headerFootersDiff; + const partsDiff = payload.partsDiff; if (!Array.isArray(docDiffs)) { throw new DiffServiceError('INVALID_INPUT', 'Diff payload.docDiffs must be an array.'); @@ -593,6 +630,9 @@ function parseDiffPayloadContents(payload: Record): DiffResult ) { throw new DiffServiceError('INVALID_INPUT', 'Diff payload.headerFootersDiff must be a plain object or null.'); } + if (partsDiff !== null && partsDiff !== undefined && (typeof partsDiff !== 'object' || Array.isArray(partsDiff))) { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload.partsDiff must be a plain object or null.'); + } // Deep-clone commentDiffs so replay never holds references to caller-owned // objects. Without this, commentJSON/newCommentJSON pushed into @@ -604,6 +644,7 @@ function parseDiffPayloadContents(payload: Record): DiffResult stylesDiff: (stylesDiff ?? null) as DiffResult['stylesDiff'], numberingDiff: (numberingDiff ?? null) as DiffResult['numberingDiff'], headerFootersDiff: (headerFootersDiff ?? null) as DiffResult['headerFootersDiff'], + partsDiff: (partsDiff ?? null) as PartsDiff | null, }; } @@ -715,6 +756,13 @@ function validateSnapshotPayload(payload: Record): void { ) { throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.headerFooters must be a plain object or null.'); } + if ( + payload.partsState !== null && + payload.partsState !== undefined && + (typeof payload.partsState !== 'object' || Array.isArray(payload.partsState)) + ) { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot payload.partsState must be a plain object or null.'); + } } function validateCoverageMatch(base: DiffCoverage, target: DiffCoverage): void { diff --git a/packages/super-editor/src/extensions/diffing/service/summary.ts b/packages/super-editor/src/extensions/diffing/service/summary.ts index 06b7f548a6..db8593ea3d 100644 --- a/packages/super-editor/src/extensions/diffing/service/summary.ts +++ b/packages/super-editor/src/extensions/diffing/service/summary.ts @@ -16,6 +16,7 @@ export function buildDiffSummary(diff: DiffResult): DiffSummary { const stylesHasChanges = diff.stylesDiff !== null; const numberingHasChanges = diff.numberingDiff !== null; const headerFootersHasChanges = diff.headerFootersDiff !== null; + const partsHasChanges = diff.partsDiff !== null; const changedComponents: DiffSummary['changedComponents'] = []; if (bodyHasChanges) changedComponents.push('body'); @@ -23,6 +24,7 @@ export function buildDiffSummary(diff: DiffResult): DiffSummary { if (stylesHasChanges) changedComponents.push('styles'); if (numberingHasChanges) changedComponents.push('numbering'); if (headerFootersHasChanges) changedComponents.push('headerFooters'); + if (partsHasChanges) changedComponents.push('parts'); return { hasChanges: changedComponents.length > 0, @@ -32,5 +34,6 @@ export function buildDiffSummary(diff: DiffResult): DiffSummary { styles: { hasChanges: stylesHasChanges }, numbering: { hasChanges: numberingHasChanges }, headerFooters: { hasChanges: headerFootersHasChanges }, + parts: { hasChanges: partsHasChanges }, }; } From 4dd2dabf1679eddae35bd148c3d54658e7ac04ef Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 13:35:10 -0300 Subject: [PATCH 11/41] 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. --- .../diffing/algorithm/parts-diffing.ts | 228 ++++++++++++++++-- .../src/extensions/diffing/computeDiff.ts | 9 +- .../src/extensions/diffing/diffing.js | 8 + .../extensions/diffing/headerFooters.test.ts | 88 +++++++ .../extensions/diffing/replay/replay-parts.ts | 61 ++++- .../diffing/service/diff-service.ts | 14 +- 6 files changed, 379 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 7776e60b44..ec95610a55 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -1,3 +1,22 @@ +import { resolveOpcTargetPath } from '../../../core/super-converter/helpers.js'; +import type { HeaderFooterKind, HeaderFooterState, HeaderFootersDiff } from './header-footer-diffing'; + +export interface PartSnapshot { + kind: 'xml' | 'binary'; + content: unknown; +} + +export interface HeaderFooterPartClosure { + refId: string; + kind: HeaderFooterKind; + partPath: string; + parts: Record; +} + +export interface PartsState { + headerFooterClosures: Record; +} + /** * Generic part-level diff payload. * @@ -5,28 +24,201 @@ * normalized parts without requiring OOXML tree diffs. */ export interface PartsDiff { - upserts: Record< - string, - | { - kind: 'xml'; - content: Record; - } - | { - kind: 'binary'; - encoding: 'base64'; - content: string; - } - >; + upserts: Record; deletes: string[]; } /** - * Placeholder parts diff computation. + * Minimal editor shape needed to capture part closures. + * + * Header/footer part fidelity currently depends on `convertedXml` for XML + * parts and the editor media stores for binary targets. + */ +export type PartsStateEditor = { + converter?: { + convertedXml?: Record; + } | null; + options?: { + mediaFiles?: Record; + }; + storage?: { + image?: { + media?: Record; + }; + }; +}; + +const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels'; + +/** + * Captures all package closures reachable from the editor's current + * header/footer parts. + */ +export function capturePartsState( + editor: PartsStateEditor, + headerFooters: HeaderFooterState | null | undefined, +): PartsState { + const convertedXml = editor.converter?.convertedXml ?? {}; + const mediaStore = getMediaStore(editor); + const headerFooterClosures: Record = {}; + + for (const part of headerFooters?.parts ?? []) { + headerFooterClosures[part.refId] = { + refId: part.refId, + kind: part.kind, + partPath: part.partPath, + parts: collectPartClosure(part.partPath, convertedXml, mediaStore), + }; + } + + return { headerFooterClosures }; +} + +/** + * Computes a parts diff from changed header/footer parts. * - * The first implementation slice only threads partsDiff through the - * diff/replay pipeline so later changes can populate it without reshaping - * the service contract again. + * This first slice scopes parts replay to header/footer roots only. */ -export function diffParts(): PartsDiff | null { - return null; +export function diffParts( + headerFootersDiff: HeaderFootersDiff | null | undefined, + previousPartsState: PartsState | null | undefined, + nextPartsState: PartsState | null | undefined, +): PartsDiff | null { + if (!headerFootersDiff) { + return null; + } + + const upserts: Record = {}; + const deletes = new Set(); + + for (const part of [...headerFootersDiff.addedParts, ...headerFootersDiff.modifiedParts]) { + const closure = nextPartsState?.headerFooterClosures?.[part.refId]; + if (!closure) continue; + for (const [partPath, snapshot] of Object.entries(closure.parts)) { + upserts[partPath] = structuredClone(snapshot); + deletes.delete(partPath); + } + } + + for (const part of headerFootersDiff.removedParts) { + const closure = previousPartsState?.headerFooterClosures?.[part.refId]; + if (closure) { + for (const partPath of Object.keys(closure.parts)) { + if (!(partPath in upserts)) { + deletes.add(partPath); + } + } + continue; + } + + deletes.add(part.partPath); + const relsPath = toRelsPathForPart(part.partPath); + if (relsPath) { + deletes.add(relsPath); + } + } + + if (Object.keys(upserts).length === 0 && deletes.size === 0) { + return null; + } + + return { + upserts, + deletes: [...deletes].sort(), + }; +} + +function getMediaStore(editor: PartsStateEditor): Record { + return { + ...(editor.options?.mediaFiles ?? {}), + ...(editor.storage?.image?.media ?? {}), + }; +} + +function collectPartClosure( + partPath: string, + convertedXml: Record, + mediaStore: Record, +): Record { + const snapshots: Record = {}; + const visited = new Set(); + collectPartAndDependencies(partPath, convertedXml, mediaStore, snapshots, visited); + return snapshots; +} + +function collectPartAndDependencies( + partPath: string, + convertedXml: Record, + mediaStore: Record, + snapshots: Record, + visited: Set, +): void { + if (visited.has(partPath)) { + return; + } + visited.add(partPath); + + const xmlPart = convertedXml[partPath]; + if (xmlPart && typeof xmlPart === 'object') { + snapshots[partPath] = { + kind: 'xml', + content: structuredClone(xmlPart), + }; + } else if (partPath in mediaStore) { + snapshots[partPath] = { + kind: 'binary', + content: structuredClone(mediaStore[partPath]), + }; + return; + } else { + return; + } + + const relsPath = toRelsPathForPart(partPath); + const relsPart = relsPath ? convertedXml[relsPath] : undefined; + if (!relsPath || !relsPart || typeof relsPart !== 'object') { + return; + } + + snapshots[relsPath] = { + kind: 'xml', + content: structuredClone(relsPart), + }; + + const relationships = readRelationships(relsPart); + const baseDir = getPartBaseDir(partPath); + for (const relationship of relationships) { + if (String(relationship.attributes?.TargetMode ?? '') === 'External') { + continue; + } + const target = String(relationship.attributes?.Target ?? ''); + const targetPath = resolveOpcTargetPath(target, baseDir); + if (!targetPath) { + continue; + } + collectPartAndDependencies(targetPath, convertedXml, mediaStore, snapshots, visited); + } +} + +function readRelationships(relsPart: unknown): Array<{ attributes?: Record }> { + const root = ( + relsPart as { elements?: Array<{ name?: string; elements?: Array<{ attributes?: Record }> }> } + )?.elements?.find((entry) => entry.name === 'Relationships'); + return Array.isArray(root?.elements) ? root.elements : []; +} + +function getPartBaseDir(partPath: string): string { + const lastSlash = partPath.lastIndexOf('/'); + return lastSlash >= 0 ? partPath.slice(0, lastSlash) : ''; +} + +function toRelsPathForPart(partPath: string): string | null { + if (partPath === DOCUMENT_RELS_PATH) { + return null; + } + const fileName = partPath.split('/').pop(); + if (!fileName) { + return null; + } + return `word/_rels/${fileName}.rels`; } diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 89eaba5cc0..0773dad76a 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -2,7 +2,7 @@ import type { Node as PMNode, Schema } from 'prosemirror-model'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import { diffComments, type CommentInput, type CommentDiff } from './algorithm/comment-diffing'; import { diffHeaderFooters, type HeaderFooterState, type HeaderFootersDiff } from './algorithm/header-footer-diffing'; -import { diffParts, type PartsDiff } from './algorithm/parts-diffing'; +import { diffParts, type PartsDiff, type PartsState } from './algorithm/parts-diffing'; import { diffNodes, normalizeNodes, type NodeDiff } from './algorithm/generic-diffing'; import { diffStyles, type StylesDiff } from './algorithm/styles-diffing'; import { diffNumbering, type NumberingDiff } from './algorithm/numbering-diffing'; @@ -60,13 +60,16 @@ export function computeDiff( newNumbering: NumberingProperties | null | undefined = null, oldHeaderFooters: HeaderFooterState | null | undefined = null, newHeaderFooters: HeaderFooterState | null | undefined = null, + oldPartsState: PartsState | null | undefined = null, + newPartsState: PartsState | null | undefined = null, ): DiffResult { + const headerFootersDiff = diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema); return { docDiffs: diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)), commentDiffs: diffComments(oldComments, newComments, schema), stylesDiff: diffStyles(oldStyles, newStyles), numberingDiff: diffNumbering(oldNumbering, newNumbering), - headerFootersDiff: diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema), - partsDiff: diffParts(), + headerFootersDiff, + partsDiff: diffParts(headerFootersDiff, oldPartsState, newPartsState), }; } diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index ce212e9a73..964313c71e 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -3,6 +3,7 @@ import { Extension } from '@core/Extension.js'; import { computeDiff } from './computeDiff.ts'; import { replayDiffs } from './replayDiffs.ts'; import { captureHeaderFooterState } from './algorithm/header-footer-diffing.ts'; +import { capturePartsState } from './algorithm/parts-diffing.ts'; export const Diffing = Extension.create({ name: 'documentDiffing', @@ -35,12 +36,17 @@ export const Diffing = Extension.create({ const currentNumbering = this.editor.converter?.translatedNumbering ?? null; const nextNumbering = updatedNumbering === undefined ? currentNumbering : updatedNumbering; const currentHeaderFooters = captureHeaderFooterState(this.editor); + const currentPartsState = capturePartsState(this.editor, currentHeaderFooters); const nextHeaderFooters = updatedHeaderFooters === undefined ? currentHeaderFooters : updatedHeaderFooters?.state && updatedHeaderFooters?.converter ? captureHeaderFooterState(updatedHeaderFooters) : updatedHeaderFooters; + const nextPartsState = + updatedHeaderFooters?.state && updatedHeaderFooters?.converter + ? capturePartsState(updatedHeaderFooters, nextHeaderFooters) + : null; const diffs = computeDiff( state.doc, updatedDocument, @@ -53,6 +59,8 @@ export const Diffing = Extension.create({ nextNumbering, currentHeaderFooters, nextHeaderFooters, + currentPartsState, + nextPartsState, ); return diffs; }, diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index c8f32e6b0f..6b9455de02 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -75,6 +75,10 @@ function seedPart( { type: 'element', name: kind === 'header' ? 'w:hdr' : 'w:ftr', + attributes: { + 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + }, elements: [], }, ], @@ -125,6 +129,52 @@ function seedPart( } } +function seedPartDependency( + editor: Editor, + params: { + partPath: string; + relationshipId: string; + target: string; + targetPath: string; + mediaContent: string; + }, +): void { + const { partPath, relationshipId, target, targetPath, mediaContent } = params; + const fileName = partPath.split('/').pop(); + if (!fileName) { + throw new Error(`Invalid partPath: ${partPath}`); + } + + editor.converter!.convertedXml![`word/_rels/${fileName}.rels`] = { + type: 'element', + name: 'document', + elements: [ + { + type: 'element', + name: 'Relationships', + attributes: { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' }, + elements: [ + { + type: 'element', + name: 'Relationship', + attributes: { + Id: relationshipId, + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: target, + }, + elements: [], + }, + ], + }, + ], + }; + + editor.options.mediaFiles ??= {}; + editor.options.mediaFiles[targetPath] = mediaContent; + (editor.storage.image as { media?: Record }).media ??= {}; + (editor.storage.image as { media?: Record }).media![targetPath] = mediaContent; +} + /** * Writes the body section properties used by the section resolver. * @@ -264,6 +314,44 @@ describe('Header/footer diffing', () => { } }); + it('captures and replays header part dependencies through partsDiff', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultHeader(afterEditor, 'Header with image'); + seedPartDependency(afterEditor, { + partPath: 'word/header1.xml', + relationshipId: 'rIdImage1', + target: 'media/header-logo.png', + targetPath: 'word/media/header-logo.png', + mediaContent: 'data:image/png;base64,aGVhZGVy', + }); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.partsDiff).not.toBeNull(); + expect(diff.partsDiff?.upserts['word/_rels/header1.xml.rels']).toBeTruthy(); + expect(diff.partsDiff?.upserts['word/media/header-logo.png']).toBeTruthy(); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(beforeEditor.converter?.convertedXml?.['word/_rels/header1.xml.rels']).toBeTruthy(); + expect( + (beforeEditor.storage.image as { media?: Record }).media?.['word/media/header-logo.png'], + ).toBe('data:image/png;base64,aGVhZGVy'); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('exports a valid header part after replay adds a new header', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts index 5796797585..f2ba05f3df 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -18,9 +18,9 @@ type ReplayPartsEditor = { /** * Placeholder parts replay. * - * A later change will make this authoritative for parts reconstruction. - * For now it remains a validated no-op so the rest of the diff pipeline can - * adopt the new field safely. + * This first slice applies coarse upserts/deletes directly into staged + * XML/media state. It currently assumes the payload contains authoritative + * snapshots for the affected parts. */ export function replayPartsDiff({ partsDiff, @@ -29,11 +29,60 @@ export function replayPartsDiff({ partsDiff: PartsDiff | null; editor?: ReplayPartsEditor; }): ReplayResult { - void partsDiff; - void editor; - return { + const result: ReplayResult = { applied: 0, skipped: 0, warnings: [], }; + + if (!partsDiff) { + return result; + } + + if (!editor?.converter?.convertedXml) { + result.skipped += 1; + result.warnings.push('Parts replay skipped: editor converter is unavailable.'); + return result; + } + + const optionMediaStore = + (editor.options ??= {}).mediaFiles ?? ((editor.options.mediaFiles = {}), editor.options.mediaFiles); + const storageImage = (editor.storage ??= {}).image ?? ((editor.storage.image = {}), editor.storage.image); + const storageMediaStore = storageImage.media ?? ((storageImage.media = {}), storageImage.media); + + for (const [partPath, snapshot] of Object.entries(partsDiff.upserts)) { + if (snapshot.kind === 'xml') { + editor.converter.convertedXml[partPath] = structuredClone(snapshot.content); + } else { + const value = structuredClone(snapshot.content); + optionMediaStore[partPath] = value; + storageMediaStore[partPath] = structuredClone(value); + } + result.applied += 1; + } + + for (const partPath of partsDiff.deletes) { + if (partPath in editor.converter.convertedXml) { + delete editor.converter.convertedXml[partPath]; + result.applied += 1; + continue; + } + const hadOptionMedia = partPath in optionMediaStore; + const hadStorageMedia = partPath in storageMediaStore; + if (hadOptionMedia) { + delete optionMediaStore[partPath]; + } + if (hadStorageMedia) { + delete storageMediaStore[partPath]; + } + if (hadOptionMedia || hadStorageMedia) { + result.applied += 1; + } + } + + return { + applied: result.applied, + skipped: result.skipped, + warnings: result.warnings, + }; } diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index a55cc71b90..2192176de5 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -13,7 +13,8 @@ import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/st import type { DiffSnapshot, DiffPayload, DiffApplyResult, DiffCoverage } from '@superdoc/document-api'; import type { CommentInput } from '../algorithm/comment-diffing'; import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; -import type { PartsDiff } from '../algorithm/parts-diffing'; +import type { PartsDiff, PartsState } from '../algorithm/parts-diffing'; +import { capturePartsState } from '../algorithm/parts-diffing'; import { captureHeaderFooterState } from '../algorithm/header-footer-diffing'; import type { DiffResult } from '../computeDiff'; import { computeDiff } from '../computeDiff'; @@ -101,6 +102,10 @@ function getEditorHeaderFooters(editor: DiffServiceEditor): HeaderFooterState { return captureHeaderFooterState(editor); } +function getEditorPartsState(editor: DiffServiceEditor, headerFooters: HeaderFooterState | null): PartsState { + return capturePartsState(editor, headerFooters); +} + /** * Builds the canonical fingerprint input for one coverage profile. * @@ -141,6 +146,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { const styles = getEditorStyles(editor); const numbering = getEditorNumbering(editor); const headerFooters = getEditorHeaderFooters(editor); + const partsState = getEditorPartsState(editor, headerFooters); const canonical = buildCanonicalStateForCoverage(doc, comments, styles, numbering, headerFooters, V2_COVERAGE); const fingerprint = computeFingerprint(canonical); @@ -159,7 +165,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { styles: styles as unknown as Record | null, numbering: numbering as unknown as Record | null, headerFooters: headerFooters as unknown as Record, - partsState: null, + partsState: partsState as unknown as Record, }), }; } @@ -188,6 +194,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif const targetStyles = targetSnapshot.payload.styles as StylesDocumentProperties | null; const targetNumbering = targetSnapshot.payload.numbering as NumberingProperties | null; const targetHeaderFooters = (targetSnapshot.payload.headerFooters ?? null) as HeaderFooterState | null; + const targetPartsState = (targetSnapshot.payload.partsState ?? null) as PartsState | null; const targetDoc = parseDocPayload(editor.state.schema, targetSnapshot.payload.doc); // Re-derive target fingerprint from payload to guard against tampered wrappers. @@ -225,6 +232,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif const baseStyles = getEditorStyles(editor); const baseNumbering = getEditorNumbering(editor); const baseHeaderFooters = targetCoverage.headerFooters ? getEditorHeaderFooters(editor) : null; + const basePartsState = targetCoverage.headerFooters ? getEditorPartsState(editor, baseHeaderFooters) : null; const baseCanonical = buildCanonicalStateForCoverage( baseDoc, baseComments, @@ -252,6 +260,8 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif targetNumbering, baseHeaderFooters, targetHeaderFooters, + basePartsState, + targetPartsState, ); } catch (err) { if (err instanceof DiffServiceError) throw err; From 7d2722e4d7dabcaa34df7c23b8442847210e2332 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 14:27:08 -0300 Subject: [PATCH 12/41] 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. --- .../src/core/Editor.lifecycle.test.ts | 40 +++++++++++++++++++ packages/super-editor/src/core/Editor.ts | 35 +++++++++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/core/Editor.lifecycle.test.ts b/packages/super-editor/src/core/Editor.lifecycle.test.ts index a25e8ab7c3..81857615cb 100644 --- a/packages/super-editor/src/core/Editor.lifecycle.test.ts +++ b/packages/super-editor/src/core/Editor.lifecycle.test.ts @@ -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', () => { diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index d1e1ea96ad..4461e65bbf 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -97,6 +97,27 @@ const PIXELS_PER_INCH = 96; const MAX_HEIGHT_BUFFER_PX = 50; const MAX_WIDTH_BUFFER_PX = 20; +type ExtensionInstanceLike = { + type?: string; + config?: Record; + constructor?: new (config: Record) => unknown; +}; + +const cloneExtensionInstance = (extension: T): T => { + const config = extension?.config; + const ExtensionCtor = extension?.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). @@ -2003,12 +2024,16 @@ export class Editor extends EventEmitter { ]; 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); } /** From 09ab7a124bd8c24afaa540db038b3c1fa73351cc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 14:29:13 -0300 Subject: [PATCH 13/41] 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. --- .../diffing/algorithm/parts-diffing.ts | 138 ++++++++++++++---- .../src/extensions/diffing/computeDiff.ts | 5 +- .../extensions/diffing/replayDiffs.test.js | 55 +++++++ .../diffing/service/diff-service.test.ts | 44 ++++++ 4 files changed, 211 insertions(+), 31 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index ec95610a55..faa9d72e41 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -14,6 +14,7 @@ export interface HeaderFooterPartClosure { } export interface PartsState { + bodyClosure: Record; headerFooterClosures: Record; } @@ -31,8 +32,8 @@ export interface PartsDiff { /** * Minimal editor shape needed to capture part closures. * - * Header/footer part fidelity currently depends on `convertedXml` for XML - * parts and the editor media stores for binary targets. + * Part fidelity currently depends on `convertedXml` for XML parts and the + * editor media stores for binary targets. */ export type PartsStateEditor = { converter?: { @@ -51,8 +52,8 @@ export type PartsStateEditor = { const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels'; /** - * Captures all package closures reachable from the editor's current - * header/footer parts. + * Captures the body and header/footer part closures needed for coarse + * parts-aware replay. */ export function capturePartsState( editor: PartsStateEditor, @@ -71,50 +72,68 @@ export function capturePartsState( }; } - return { headerFooterClosures }; + return { + bodyClosure: collectBodyClosure(convertedXml, mediaStore), + headerFooterClosures, + }; } /** - * Computes a parts diff from changed header/footer parts. + * Computes a coarse parts diff for body and header/footer changes. * - * This first slice scopes parts replay to header/footer roots only. + * Body changes currently use a conservative strategy: any document diff + * causes the captured body relationship closure to be compared and emitted. */ export function diffParts( + docDiffs: Array, headerFootersDiff: HeaderFootersDiff | null | undefined, previousPartsState: PartsState | null | undefined, nextPartsState: PartsState | null | undefined, ): PartsDiff | null { - if (!headerFootersDiff) { - return null; - } - const upserts: Record = {}; const deletes = new Set(); - for (const part of [...headerFootersDiff.addedParts, ...headerFootersDiff.modifiedParts]) { - const closure = nextPartsState?.headerFooterClosures?.[part.refId]; - if (!closure) continue; - for (const [partPath, snapshot] of Object.entries(closure.parts)) { - upserts[partPath] = structuredClone(snapshot); - deletes.delete(partPath); + if (docDiffs.length > 0) { + for (const [partPath, snapshot] of Object.entries(nextPartsState?.bodyClosure ?? {})) { + const previous = previousPartsState?.bodyClosure?.[partPath]; + if (!previous || !partSnapshotsEqual(previous, snapshot)) { + upserts[partPath] = structuredClone(snapshot); + } + } + + for (const partPath of Object.keys(previousPartsState?.bodyClosure ?? {})) { + if (!(partPath in (nextPartsState?.bodyClosure ?? {})) && !(partPath in upserts)) { + deletes.add(partPath); + } } } - for (const part of headerFootersDiff.removedParts) { - const closure = previousPartsState?.headerFooterClosures?.[part.refId]; - if (closure) { - for (const partPath of Object.keys(closure.parts)) { - if (!(partPath in upserts)) { - deletes.add(partPath); - } + if (headerFootersDiff) { + for (const part of [...headerFootersDiff.addedParts, ...headerFootersDiff.modifiedParts]) { + const closure = nextPartsState?.headerFooterClosures?.[part.refId]; + if (!closure) continue; + for (const [partPath, snapshot] of Object.entries(closure.parts)) { + upserts[partPath] = structuredClone(snapshot); + deletes.delete(partPath); } - continue; } - deletes.add(part.partPath); - const relsPath = toRelsPathForPart(part.partPath); - if (relsPath) { - deletes.add(relsPath); + for (const part of headerFootersDiff.removedParts) { + const closure = previousPartsState?.headerFooterClosures?.[part.refId]; + if (closure) { + for (const partPath of Object.keys(closure.parts)) { + if (!(partPath in upserts)) { + deletes.add(partPath); + } + } + continue; + } + + deletes.add(part.partPath); + const relsPath = toRelsPathForPart(part.partPath); + if (relsPath) { + deletes.add(relsPath); + } } } @@ -146,6 +165,42 @@ function collectPartClosure( return snapshots; } +function collectBodyClosure( + convertedXml: Record, + mediaStore: Record, +): Record { + const relsPart = convertedXml[DOCUMENT_RELS_PATH]; + if (!relsPart || typeof relsPart !== 'object') { + return {}; + } + + const snapshots: Record = { + [DOCUMENT_RELS_PATH]: { + kind: 'xml', + content: structuredClone(relsPart), + }, + }; + const visited = new Set([DOCUMENT_RELS_PATH]); + + for (const relationship of readRelationships(relsPart)) { + const type = String(relationship.attributes?.Type ?? ''); + if (!shouldCaptureBodyRelationship(type)) { + continue; + } + if (String(relationship.attributes?.TargetMode ?? '') === 'External') { + continue; + } + const target = String(relationship.attributes?.Target ?? ''); + const targetPath = resolveOpcTargetPath(target, 'word'); + if (!targetPath) { + continue; + } + collectPartAndDependencies(targetPath, convertedXml, mediaStore, snapshots, visited); + } + + return snapshots; +} + function collectPartAndDependencies( partPath: string, convertedXml: Record, @@ -222,3 +277,28 @@ function toRelsPathForPart(partPath: string): string | null { } return `word/_rels/${fileName}.rels`; } + +function shouldCaptureBodyRelationship(type: string): boolean { + return !BODY_RELATIONSHIP_EXCLUSIONS.has(type); +} + +function partSnapshotsEqual(a: PartSnapshot, b: PartSnapshot): boolean { + return a.kind === b.kind && JSON.stringify(a.content) === JSON.stringify(b.content); +} + +const BODY_RELATIONSHIP_EXCLUSIONS = new Set([ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', + 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended', + 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds', + 'http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible', +]); diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 0773dad76a..828fe08423 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -63,13 +63,14 @@ export function computeDiff( oldPartsState: PartsState | null | undefined = null, newPartsState: PartsState | null | undefined = null, ): DiffResult { + const docDiffs = diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)); const headerFootersDiff = diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema); return { - docDiffs: diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)), + docDiffs, commentDiffs: diffComments(oldComments, newComments, schema), stylesDiff: diffStyles(oldStyles, newStyles), numberingDiff: diffNumbering(oldNumbering, newNumbering), headerFootersDiff, - partsDiff: diffParts(headerFootersDiff, oldPartsState, newPartsState), + partsDiff: diffParts(docDiffs, headerFootersDiff, oldPartsState, newPartsState), }; } diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js index 8064eba23a..3f4006501a 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js @@ -92,6 +92,43 @@ const expectReplayMatchesFixture = async (beforeName, afterName) => { } }; +/** + * Replays diffs through the direct compare/replay command path using the + * compare editor instance so part closures can be captured. + * + * @param {string} beforeName DOCX fixture filename for the baseline. + * @param {string} afterName DOCX fixture filename for the updated doc. + * @returns {Promise} + */ +const expectDirectReplayPopulatesBodyMedia = async (beforeName, afterName, applyTrackedChanges = false) => { + const testUser = { name: 'Test User', email: 'test@example.com' }; + const beforeEditor = await getEditorFromFixture(beforeName, applyTrackedChanges ? testUser : undefined); + const afterEditor = await getEditorFromFixture(afterName); + + try { + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + const mediaUpserts = Object.keys(diff.partsDiff?.upserts ?? {}).filter((path) => path.startsWith('word/media/')); + expect(mediaUpserts.length).toBeGreaterThan(0); + + const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges }); + expect(success).toBe(true); + + for (const path of mediaUpserts) { + expect(beforeEditor.storage.image.media?.[path]).toBeDefined(); + } + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } +}; + /** * Replays diffs with applyTrackedChanges disabled while track changes mode is active, * asserting replay does not create tracked marks. @@ -497,3 +534,21 @@ describe('investigate replay issues', () => { } }); }); + +describe('parts-aware replay', () => { + it('populates body media when replaying direct diffs with a compare editor', async () => { + await expectDirectReplayPopulatesBodyMedia('diff_before6.docx', 'diff_after6.docx'); + }); + + it('populates body media when replaying tracked direct diffs with a compare editor', async () => { + await expectDirectReplayPopulatesBodyMedia('diff_before6.docx', 'diff_after6.docx', true); + }); + + it('populates body media when replaying direct diffs for diff_before19/diff_after19', async () => { + await expectDirectReplayPopulatesBodyMedia('diff_before19.docx', 'diff_after19.docx'); + }); + + it('populates body media when replaying tracked direct diffs for diff_before19/diff_after19', async () => { + await expectDirectReplayPopulatesBodyMedia('diff_before19.docx', 'diff_after19.docx', true); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 0d14187025..2df11e4706 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -4,6 +4,7 @@ import { Editor } from '@core/Editor.js'; import { BLANK_DOCX_BASE64 } from '@core/blank-docx.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; +import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; import type { CommentInput } from '../algorithm/comment-diffing.ts'; import { captureHeaderFooterState } from '../algorithm/header-footer-diffing.ts'; import { applyDiffPayload, captureSnapshot, compareToSnapshot } from './index.ts'; @@ -194,6 +195,24 @@ async function reopenExportedDocument(exported: Blob | Buffer): Promise }); } +async function openFixtureDocument(name: string): Promise { + const buffer = await getTestDataAsBuffer(`diffing/${name}`); + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + return new Editor({ + isHeadless: true, + extensions: getStarterExtensions(), + documentId: `fixture-${name}`, + content: docx, + mode: 'docx', + media, + mediaFiles, + fonts, + annotations: true, + user: TEST_USER, + }); +} + describe('diff-service tracked apply', () => { it('applies appended text as tracked changes', async () => { const baseEditor = await openBlankDocxWithText('Section 1. Payment is due within thirty days.'); @@ -265,6 +284,31 @@ describe('diff-service tracked apply', () => { } }); + it('replays body image dependencies through partsDiff', async () => { + const baseEditor = await openFixtureDocument('diff_before6.docx'); + const targetEditor = await openFixtureDocument('diff_after6.docx'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const mediaUpserts = Object.keys( + (diff.payload.partsDiff as Record | null)?.upserts ?? {}, + ).filter((path) => path.startsWith('word/media/')); + + expect(mediaUpserts.length).toBeGreaterThan(0); + + const { tr } = applyDiffPayload(baseEditor, diff, { changeMode: 'direct' }); + baseEditor.dispatch(tr); + + for (const path of mediaUpserts) { + expect((baseEditor.storage.image as { media?: Record }).media?.[path]).toBeDefined(); + } + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + it('rejects snapshots whose comment identity was tampered after capture', async () => { const baseEditor = await openBlankDocxWithText('Base document.'); const targetEditor = await openBlankDocxWithText('Base document.'); From 6eef5097eee6e65836fec7167d67e04b731531b6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 14:43:57 -0300 Subject: [PATCH 14/41] 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 --- packages/document-api/src/contract/schemas.ts | 5 + packages/document-api/src/diff/diff.ts | 9 ++ packages/document-api/src/diff/diff.types.ts | 5 + .../diffing/service/canonicalize.ts | 4 + .../diffing/service/diff-service.test.ts | 48 ++++++++ .../diffing/service/diff-service.ts | 110 +++++++++++++++++- .../diffing/service/fingerprint.test.ts | 14 ++- 7 files changed, 192 insertions(+), 3 deletions(-) diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index cbd4ccd95d..ab3a417a19 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2804,6 +2804,7 @@ const diffSnapshotSchema: JsonSchema = objectSchema( version: { type: 'string', enum: ['sd-diff-snapshot/v1', 'sd-diff-snapshot/v2'] }, engine: { type: 'string', enum: ['super-editor'] }, fingerprint: { type: 'string' }, + partsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, payload: { type: 'object', description: 'Opaque engine-owned snapshot data.' }, }, @@ -2816,6 +2817,8 @@ const diffPayloadSchema: JsonSchema = objectSchema( engine: { type: 'string', enum: ['super-editor'] }, baseFingerprint: { type: 'string' }, targetFingerprint: { type: 'string' }, + basePartsFingerprint: { type: 'string' }, + targetPartsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, summary: diffSummarySchema, payload: { type: 'object', description: 'Opaque engine-owned diff data.' }, @@ -2828,6 +2831,8 @@ const diffApplyResultSchema: JsonSchema = objectSchema( appliedOperations: { type: 'integer' }, baseFingerprint: { type: 'string' }, targetFingerprint: { type: 'string' }, + basePartsFingerprint: { type: 'string' }, + targetPartsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, summary: diffSummarySchema, diagnostics: { type: 'array', items: { type: 'string' } }, diff --git a/packages/document-api/src/diff/diff.ts b/packages/document-api/src/diff/diff.ts index 55b8d31f2f..5d837029a4 100644 --- a/packages/document-api/src/diff/diff.ts +++ b/packages/document-api/src/diff/diff.ts @@ -66,6 +66,9 @@ function validateSnapshotWrapper(snapshot: unknown): asserts snapshot is DiffSna if (typeof snapshot.fingerprint !== 'string') { throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.fingerprint must be a string.'); } + if (snapshot.version === 'sd-diff-snapshot/v2' && typeof snapshot.partsFingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.partsFingerprint must be a string.'); + } if (!isRecord(snapshot.coverage)) { throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.coverage must be an object.'); } @@ -93,6 +96,12 @@ function validateDiffPayloadWrapper(diff: unknown): asserts diff is DiffPayload if (typeof diff.targetFingerprint !== 'string') { throw new DocumentApiValidationError('INVALID_INPUT', 'diff.targetFingerprint must be a string.'); } + if (diff.version === 'sd-diff-payload/v2' && typeof diff.basePartsFingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.basePartsFingerprint must be a string.'); + } + if (diff.version === 'sd-diff-payload/v2' && typeof diff.targetPartsFingerprint !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'diff.targetPartsFingerprint must be a string.'); + } if (!isRecord(diff.coverage)) { throw new DocumentApiValidationError('INVALID_INPUT', 'diff.coverage must be an object.'); } diff --git a/packages/document-api/src/diff/diff.types.ts b/packages/document-api/src/diff/diff.types.ts index e9f5e39780..5a47ebfbc3 100644 --- a/packages/document-api/src/diff/diff.types.ts +++ b/packages/document-api/src/diff/diff.types.ts @@ -35,6 +35,7 @@ export interface DiffSnapshot { version: 'sd-diff-snapshot/v1' | 'sd-diff-snapshot/v2'; engine: DiffEngineId; fingerprint: string; + partsFingerprint?: string; coverage: DiffCoverage; /** Opaque engine-owned snapshot data. Do not inspect or modify. */ payload: Record; @@ -62,6 +63,8 @@ export interface DiffPayload { engine: DiffEngineId; baseFingerprint: string; targetFingerprint: string; + basePartsFingerprint?: string; + targetPartsFingerprint?: string; coverage: DiffCoverage; summary: DiffSummary; /** Opaque engine-owned diff data. Do not inspect or modify. */ @@ -73,6 +76,8 @@ export interface DiffApplyResult { appliedOperations: number; baseFingerprint: string; targetFingerprint: string; + basePartsFingerprint?: string; + targetPartsFingerprint?: string; coverage: DiffCoverage; summary: DiffSummary; diagnostics: string[]; diff --git a/packages/super-editor/src/extensions/diffing/service/canonicalize.ts b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts index bb142f44ca..d604884210 100644 --- a/packages/super-editor/src/extensions/diffing/service/canonicalize.ts +++ b/packages/super-editor/src/extensions/diffing/service/canonicalize.ts @@ -9,6 +9,7 @@ import type { Node as PMNode } from 'prosemirror-model'; import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; import type { CommentInput } from '../algorithm/comment-diffing'; import type { HeaderFooterState } from '../algorithm/header-footer-diffing'; +import type { PartsState } from '../algorithm/parts-diffing'; import { COMMENT_ATTRS_DIFF_IGNORED_KEYS } from '../algorithm/comment-diffing'; import { normalizeDocJSON } from '../algorithm/semantic-normalization'; @@ -19,6 +20,7 @@ export interface CanonicalDiffableState { styles: Record | null; numbering: Record | null; headerFooters: HeaderFooterState | null; + partsState: PartsState | null; } /** @@ -65,6 +67,7 @@ export function buildCanonicalDiffableState( styles: StylesDocumentProperties | null | undefined, numbering: NumberingProperties | null | undefined, headerFooters: HeaderFooterState | null | undefined, + partsState: PartsState | null | undefined, ): CanonicalDiffableState { return { body: normalizeDocJSON(doc.toJSON() as Record), @@ -72,6 +75,7 @@ export function buildCanonicalDiffableState( styles: styles ? (styles as unknown as Record) : null, numbering: numbering ? (numbering as unknown as Record) : null, headerFooters: headerFooters ? structuredClone(headerFooters) : null, + partsState: partsState ? structuredClone(partsState) : null, }; } diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 2df11e4706..9780f94780 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -352,6 +352,7 @@ describe('diff-service tracked apply', () => { targetEditor.converter?.translatedLinkedStyles ?? null, targetEditor.converter?.translatedNumbering ?? null, null, + null, ), ); @@ -386,6 +387,7 @@ describe('diff-service tracked apply', () => { targetEditor.converter?.translatedLinkedStyles ?? null, targetEditor.converter?.translatedNumbering ?? null, null, + null, ), ); @@ -471,4 +473,50 @@ describe('diff-service tracked apply', () => { targetEditor.destroy?.(); } }); + + it('rejects apply when semantic state matches but parts state differs', async () => { + const baseEditor = await openFixtureDocument('diff_before19.docx'); + const targetEditor = await openFixtureDocument('diff_after19.docx'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const baseSnapshot = captureSnapshot(baseEditor); + expect(baseSnapshot.fingerprint).toBe(diff.baseFingerprint); + expect(baseSnapshot.partsFingerprint).toBe(diff.basePartsFingerprint); + + const relsPart = baseEditor.converter?.convertedXml?.['word/_rels/document.xml.rels'] as + | { + elements?: Array<{ + name?: string; + elements?: Array<{ name?: string; attributes?: Record }>; + }>; + } + | undefined; + const relsRoot = relsPart?.elements?.find((entry) => entry.name === 'Relationships'); + relsRoot?.elements?.push({ + name: 'Relationship', + attributes: { + Id: 'rId999', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/unexpected-image.png', + }, + }); + baseEditor.options.mediaFiles ??= {}; + baseEditor.storage.image.media ??= {}; + baseEditor.options.mediaFiles['word/media/unexpected-image.png'] = 'base64-unexpected'; + baseEditor.storage.image.media['word/media/unexpected-image.png'] = 'base64-unexpected'; + + const mutatedSnapshot = captureSnapshot(baseEditor); + expect(mutatedSnapshot.fingerprint).toBe(baseSnapshot.fingerprint); + expect(mutatedSnapshot.partsFingerprint).not.toBe(baseSnapshot.partsFingerprint); + + expect(() => applyDiffPayload(baseEditor, diff, { changeMode: 'direct' })).toThrowError( + /parts fingerprint mismatch/i, + ); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); }); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index 2192176de5..f4c8b2dac2 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -123,9 +123,17 @@ function buildCanonicalStateForCoverage( styles: StylesDocumentProperties | null, numbering: NumberingProperties | null, headerFooters: HeaderFooterState | null, + partsState: PartsState | null, coverage: DiffCoverage, ) { - return buildCanonicalDiffableState(doc, comments, styles, numbering, coverage.headerFooters ? headerFooters : null); + return buildCanonicalDiffableState( + doc, + comments, + styles, + numbering, + coverage.headerFooters ? headerFooters : null, + coverage.headerFooters ? partsState : null, + ); } // --------------------------------------------------------------------------- @@ -148,13 +156,24 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { const headerFooters = getEditorHeaderFooters(editor); const partsState = getEditorPartsState(editor, headerFooters); - const canonical = buildCanonicalStateForCoverage(doc, comments, styles, numbering, headerFooters, V2_COVERAGE); + const canonical = buildCanonicalStateForCoverage(doc, comments, styles, numbering, headerFooters, null, V2_COVERAGE); + const partsCanonical = buildCanonicalStateForCoverage( + doc, + comments, + styles, + numbering, + headerFooters, + partsState, + V2_COVERAGE, + ); const fingerprint = computeFingerprint(canonical); + const partsFingerprint = computeFingerprint(partsCanonical); return { version: SNAPSHOT_VERSION_V2, engine: ENGINE_ID, fingerprint, + partsFingerprint, coverage: { ...V2_COVERAGE }, // Deep-clone every slot so the snapshot is immutable. doc.toJSON() // already returns a fresh tree; the rest are live references that would @@ -181,6 +200,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: DiffSnapshot): DiffPayload { validateEngine(targetSnapshot.engine); validateSnapshotVersion(targetSnapshot.version); + validateSnapshotFingerprints(targetSnapshot); const expectedCoverage = getCoverageForSnapshotVersion(targetSnapshot.version); const targetCoverage = targetSnapshot.coverage; @@ -209,6 +229,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif targetStyles, targetNumbering, targetHeaderFooters, + null, targetCoverage, ); reDerivedFingerprint = computeFingerprint(targetCanonical); @@ -225,6 +246,25 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif `Target snapshot fingerprint does not match re-derived value. The snapshot may have been tampered with.`, ); } + if (targetSnapshot.version === SNAPSHOT_VERSION_V2) { + const reDerivedPartsFingerprint = computeFingerprint( + buildCanonicalStateForCoverage( + targetDoc, + targetComments, + targetStyles, + targetNumbering, + targetHeaderFooters, + targetPartsState, + targetCoverage, + ), + ); + if (reDerivedPartsFingerprint !== targetSnapshot.partsFingerprint) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Target snapshot parts fingerprint does not match re-derived value. The snapshot may have been tampered with.`, + ); + } + } // Compute base fingerprint const baseDoc = editor.state.doc; @@ -239,9 +279,24 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif baseStyles, baseNumbering, baseHeaderFooters, + null, targetCoverage, ); const baseFingerprint = computeFingerprint(baseCanonical); + const basePartsFingerprint = + targetSnapshot.version === SNAPSHOT_VERSION_V2 + ? computeFingerprint( + buildCanonicalStateForCoverage( + baseDoc, + baseComments, + baseStyles, + baseNumbering, + baseHeaderFooters, + basePartsState, + targetCoverage, + ), + ) + : null; // Compute raw diff. Wrap in try-catch so malformed nested comment bodies // (e.g. textJson that passes structural validation but fails inside @@ -286,6 +341,8 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif engine: ENGINE_ID, baseFingerprint, targetFingerprint: targetSnapshot.fingerprint, + basePartsFingerprint: basePartsFingerprint ?? undefined, + targetPartsFingerprint: targetSnapshot.partsFingerprint, coverage: { ...targetCoverage }, summary, // Detach the payload from editor-owned objects before returning it across @@ -321,6 +378,7 @@ export function applyDiffPayload( ): ApplyDiffResult { validateEngine(diffPayload.engine); validatePayloadVersion(diffPayload.version); + validatePayloadFingerprints(diffPayload); // Verify base fingerprint matches current document const baseDoc = editor.state.doc; @@ -328,12 +386,14 @@ export function applyDiffPayload( const baseStyles = getEditorStyles(editor); const baseNumbering = getEditorNumbering(editor); const baseHeaderFooters = getEditorHeaderFooters(editor); + const basePartsState = getEditorPartsState(editor, baseHeaderFooters); const baseCanonical = buildCanonicalStateForCoverage( baseDoc, baseComments, baseStyles, baseNumbering, baseHeaderFooters, + null, diffPayload.coverage, ); const currentFingerprint = computeFingerprint(baseCanonical); @@ -345,6 +405,26 @@ export function applyDiffPayload( `The document may have changed since the diff was computed. Re-run diff.compare against the current state.`, ); } + if (diffPayload.version === PAYLOAD_VERSION_V2) { + const currentPartsFingerprint = computeFingerprint( + buildCanonicalStateForCoverage( + baseDoc, + baseComments, + baseStyles, + baseNumbering, + baseHeaderFooters, + basePartsState, + diffPayload.coverage, + ), + ); + if (currentPartsFingerprint !== diffPayload.basePartsFingerprint) { + throw new DiffServiceError( + 'PRECONDITION_FAILED', + `Document parts fingerprint mismatch. Expected "${diffPayload.basePartsFingerprint}", got "${currentPartsFingerprint}". ` + + `The document's part/media state may have changed since the diff was computed. Re-run diff.compare against the current state.`, + ); + } + } // Reconstruct internal DiffResult from opaque payload with structural validation const rawDiff = parseDiffPayloadContents(diffPayload.payload); @@ -418,6 +498,8 @@ export function applyDiffPayload( appliedOperations: replayResult.appliedDiffs, baseFingerprint: diffPayload.baseFingerprint, targetFingerprint: diffPayload.targetFingerprint, + basePartsFingerprint: diffPayload.basePartsFingerprint, + targetPartsFingerprint: diffPayload.targetPartsFingerprint, coverage: { ...diffPayload.coverage }, summary: verifiedSummary, diagnostics: replayResult.warnings, @@ -733,6 +815,30 @@ function validatePayloadVersion(version: string): void { } } +function validateSnapshotFingerprints(snapshot: DiffSnapshot): void { + if (typeof snapshot.fingerprint !== 'string') { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot fingerprint must be a string.'); + } + if (snapshot.version === SNAPSHOT_VERSION_V2 && typeof snapshot.partsFingerprint !== 'string') { + throw new DiffServiceError('INVALID_INPUT', 'Snapshot partsFingerprint must be a string for v2 snapshots.'); + } +} + +function validatePayloadFingerprints(payload: DiffPayload): void { + if (typeof payload.baseFingerprint !== 'string' || typeof payload.targetFingerprint !== 'string') { + throw new DiffServiceError('INVALID_INPUT', 'Diff payload fingerprints must be strings.'); + } + if ( + payload.version === PAYLOAD_VERSION_V2 && + (typeof payload.basePartsFingerprint !== 'string' || typeof payload.targetPartsFingerprint !== 'string') + ) { + throw new DiffServiceError( + 'INVALID_INPUT', + 'Diff payload basePartsFingerprint and targetPartsFingerprint must be strings for v2 payloads.', + ); + } +} + function validateSnapshotPayload(payload: Record): void { if (payload.comments !== null && payload.comments !== undefined) { if (!Array.isArray(payload.comments)) { diff --git a/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts b/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts index f223ad25a6..9c72a3c252 100644 --- a/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/fingerprint.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import type { CanonicalDiffableState } from './canonicalize'; import { computeFingerprint } from './fingerprint'; +const STABLE_CANONICAL_STATE_HASH = '6fe514692e04f502d7a18b8bb9f2b23a15943d44abe8b270640cb0efb2254cb0'; + describe('computeFingerprint', () => { it('matches the expected SHA-256 for a stable canonical state', () => { const state: CanonicalDiffableState = { @@ -10,9 +12,11 @@ describe('computeFingerprint', () => { comments: [], styles: null, numbering: null, + headerFooters: null, + partsState: null, }; - expect(computeFingerprint(state)).toBe('66a5174811bcb593a6927a09fa130a40705a453407a6fc7777d9d3bcede7892e'); + expect(computeFingerprint(state)).toBe(STABLE_CANONICAL_STATE_HASH); }); it('changes when comment body content changes', () => { @@ -21,12 +25,16 @@ describe('computeFingerprint', () => { comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'A' }] } }], styles: null, numbering: null, + headerFooters: null, + partsState: null, }; const changedState: CanonicalDiffableState = { body: { type: 'doc' }, comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'B' }] } }], styles: null, numbering: null, + headerFooters: null, + partsState: null, }; expect(computeFingerprint(baseState)).not.toBe(computeFingerprint(changedState)); @@ -38,12 +46,16 @@ describe('computeFingerprint', () => { comments: [{ commentId: 'c1', textJson: { type: 'doc', content: [{ type: 'text', text: 'Same' }] } }], styles: null, numbering: null, + headerFooters: null, + partsState: null, }; const changedState: CanonicalDiffableState = { body: { type: 'doc' }, comments: [{ commentId: 'c2', textJson: { type: 'doc', content: [{ type: 'text', text: 'Same' }] } }], styles: null, numbering: null, + headerFooters: null, + partsState: null, }; expect(computeFingerprint(baseState)).not.toBe(computeFingerprint(changedState)); From 40ba287d0e6b223ad1e83e9071c15385d1b1a4ab Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 15:15:54 -0300 Subject: [PATCH 15/41] 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. --- .../diffing/algorithm/parts-diffing.ts | 25 +++++- .../extensions/diffing/headerFooters.test.ts | 90 +++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index faa9d72e41..957d801a54 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -92,6 +92,7 @@ export function diffParts( ): PartsDiff | null { const upserts: Record = {}; const deletes = new Set(); + const nextReachablePartPaths = collectReachablePartPaths(nextPartsState); if (docDiffs.length > 0) { for (const [partPath, snapshot] of Object.entries(nextPartsState?.bodyClosure ?? {})) { @@ -122,16 +123,18 @@ export function diffParts( const closure = previousPartsState?.headerFooterClosures?.[part.refId]; if (closure) { for (const partPath of Object.keys(closure.parts)) { - if (!(partPath in upserts)) { + if (!(partPath in upserts) && !nextReachablePartPaths.has(partPath)) { deletes.add(partPath); } } continue; } - deletes.add(part.partPath); + if (!nextReachablePartPaths.has(part.partPath)) { + deletes.add(part.partPath); + } const relsPath = toRelsPathForPart(part.partPath); - if (relsPath) { + if (relsPath && !nextReachablePartPaths.has(relsPath)) { deletes.add(relsPath); } } @@ -147,6 +150,22 @@ export function diffParts( }; } +function collectReachablePartPaths(partsState: PartsState | null | undefined): Set { + const reachable = new Set(); + + for (const partPath of Object.keys(partsState?.bodyClosure ?? {})) { + reachable.add(partPath); + } + + for (const closure of Object.values(partsState?.headerFooterClosures ?? {})) { + for (const partPath of Object.keys(closure.parts)) { + reachable.add(partPath); + } + } + + return reachable; +} + function getMediaStore(editor: PartsStateEditor): Record { return { ...(editor.options?.mediaFiles ?? {}), diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 6b9455de02..5491b6c9ba 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -186,7 +186,9 @@ function setBodySection( params: { titlePg?: boolean; headerDefault?: string | null; + headerFirst?: string | null; footerDefault?: string | null; + footerFirst?: string | null; }, ): void { const elements: Array> = [ @@ -221,6 +223,14 @@ function setBodySection( elements: [], }); } + if (params.headerFirst) { + elements.push({ + type: 'element', + name: 'w:headerReference', + attributes: { 'w:type': 'first', 'r:id': params.headerFirst }, + elements: [], + }); + } if (params.footerDefault) { elements.push({ type: 'element', @@ -229,6 +239,14 @@ function setBodySection( elements: [], }); } + if (params.footerFirst) { + elements.push({ + type: 'element', + name: 'w:footerReference', + attributes: { 'w:type': 'first', 'r:id': params.footerFirst }, + elements: [], + }); + } editor.converter!.bodySectPr = { type: 'element', @@ -433,6 +451,78 @@ describe('Header/footer diffing', () => { } }); + it('preserves shared header dependencies when removing one header', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedPart(beforeEditor, { + kind: 'header', + refId: 'rIdHeaderDefault', + partPath: 'word/header1.xml', + text: 'Default header', + }); + seedPart(beforeEditor, { + kind: 'header', + refId: 'rIdHeaderFirst', + partPath: 'word/header2.xml', + text: 'First header', + }); + seedPartDependency(beforeEditor, { + partPath: 'word/header1.xml', + relationshipId: 'rIdImage1', + target: 'media/shared-logo.png', + targetPath: 'word/media/shared-logo.png', + mediaContent: 'data:image/png;base64,c2hhcmVk', + }); + seedPartDependency(beforeEditor, { + partPath: 'word/header2.xml', + relationshipId: 'rIdImage2', + target: 'media/shared-logo.png', + targetPath: 'word/media/shared-logo.png', + mediaContent: 'data:image/png;base64,c2hhcmVk', + }); + setBodySection(beforeEditor, { + titlePg: true, + headerDefault: 'rIdHeaderDefault', + headerFirst: 'rIdHeaderFirst', + }); + + seedPart(afterEditor, { + kind: 'header', + refId: 'rIdHeaderDefault', + partPath: 'word/header1.xml', + text: 'Default header', + }); + seedPartDependency(afterEditor, { + partPath: 'word/header1.xml', + relationshipId: 'rIdImage1', + target: 'media/shared-logo.png', + targetPath: 'word/media/shared-logo.png', + mediaContent: 'data:image/png;base64,c2hhcmVk', + }); + setBodySection(afterEditor, { + headerDefault: 'rIdHeaderDefault', + }); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.headerFootersDiff?.removedParts).toHaveLength(1); + expect(diff.partsDiff?.deletes).not.toContain('word/media/shared-logo.png'); + expect(diff.partsDiff?.deletes).toContain('word/header2.xml'); + expect(diff.partsDiff?.deletes).toContain('word/_rels/header2.xml.rels'); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('keeps body replay tracked when header/footer diffs are present', async () => { const user = { name: 'Test User', email: 'test@example.com' }; const beforeEditor = await createEditor(user); From b6087f6074ddbe6ddb05e45b3159c755ad66db1d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 15:27:15 -0300 Subject: [PATCH 16/41] fix(diff): resolve .rels paths relative to the part's own directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../diffing/algorithm/parts-diffing.test.ts | 70 +++++++++++++++++++ .../diffing/algorithm/parts-diffing.ts | 10 +-- 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts new file mode 100644 index 0000000000..e977f5ceaa --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { capturePartsState } from './parts-diffing'; + +describe('parts-diffing', () => { + it('captures nested relationship parts relative to the part directory', () => { + const editor = { + converter: { + convertedXml: { + 'word/_rels/document.xml.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rIdChart1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart', + Target: 'charts/chart1.xml', + }, + }, + ], + }, + ], + }, + 'word/charts/chart1.xml': { + elements: [{ name: 'c:chartSpace', elements: [] }], + }, + 'word/charts/_rels/chart1.xml.rels': { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rIdWorkbook1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package', + Target: '../embeddings/Microsoft_Excel_Sheet1.xlsx', + }, + }, + ], + }, + ], + }, + }, + }, + options: { + mediaFiles: { + 'word/embeddings/Microsoft_Excel_Sheet1.xlsx': 'base64-embedded-workbook', + }, + }, + storage: { + image: { + media: {}, + }, + }, + }; + + const partsState = capturePartsState(editor, null); + + expect(partsState.bodyClosure['word/charts/chart1.xml']).toBeTruthy(); + expect(partsState.bodyClosure['word/charts/_rels/chart1.xml.rels']).toBeTruthy(); + expect(partsState.bodyClosure['word/embeddings/Microsoft_Excel_Sheet1.xlsx']).toEqual({ + kind: 'binary', + content: 'base64-embedded-workbook', + }); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 957d801a54..c3e29cd6ab 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -287,14 +287,16 @@ function getPartBaseDir(partPath: string): string { } function toRelsPathForPart(partPath: string): string | null { - if (partPath === DOCUMENT_RELS_PATH) { + if (partPath === DOCUMENT_RELS_PATH || partPath.endsWith('.rels')) { return null; } - const fileName = partPath.split('/').pop(); - if (!fileName) { + const lastSlash = partPath.lastIndexOf('/'); + if (lastSlash < 0 || lastSlash === partPath.length - 1) { return null; } - return `word/_rels/${fileName}.rels`; + const directory = partPath.slice(0, lastSlash); + const fileName = partPath.slice(lastSlash + 1); + return `${directory}/_rels/${fileName}.rels`; } function shouldCaptureBodyRelationship(type: string): boolean { From 58db6838d3ed56012d8dda4a1374f5f5f331b143 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 15:48:33 -0300 Subject: [PATCH 17/41] 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. --- .../src/extensions/diffing/computeDiff.ts | 4 +++- .../extensions/diffing/replayDiffs.test.js | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 828fe08423..6ada5a9ca0 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -65,12 +65,14 @@ export function computeDiff( ): DiffResult { const docDiffs = diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)); const headerFootersDiff = diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema); + const partsDiff = + oldPartsState && newPartsState ? diffParts(docDiffs, headerFootersDiff, oldPartsState, newPartsState) : null; return { docDiffs, commentDiffs: diffComments(oldComments, newComments, schema), stylesDiff: diffStyles(oldStyles, newStyles), numberingDiff: diffNumbering(oldNumbering, newNumbering), headerFootersDiff, - partsDiff: diffParts(docDiffs, headerFootersDiff, oldPartsState, newPartsState), + partsDiff, }; } diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js index 3f4006501a..d78d80c21e 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js @@ -536,6 +536,26 @@ describe('investigate replay issues', () => { }); describe('parts-aware replay', () => { + it('does not emit partsDiff for legacy compareDocuments callers without a compare editor', async () => { + const beforeEditor = await getEditorFromFixture('diff_before19.docx'); + const afterEditor = await getEditorFromFixture('diff_after19.docx'); + + try { + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + ); + + expect(diff.docDiffs.length).toBeGreaterThan(0); + expect(diff.partsDiff).toBeNull(); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('populates body media when replaying direct diffs with a compare editor', async () => { await expectDirectReplayPopulatesBodyMedia('diff_before6.docx', 'diff_after6.docx'); }); From 0ebfe7b637b4bdb036f4beda865bff3fd8a0e458 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 16:12:22 -0300 Subject: [PATCH 18/41] feat(diff): detect and replay header/footer part path renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../algorithm/header-footer-diffing.ts | 4 +- .../extensions/diffing/headerFooters.test.ts | 58 +++++++++++ .../diffing/replay/replay-header-footers.ts | 98 +++++++++++++++---- 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts index 732c3d4534..ee4ae8b7a4 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -48,6 +48,7 @@ export interface HeaderFooterState { export interface ModifiedHeaderFooterPart { refId: string; kind: HeaderFooterKind; + oldPartPath: string; partPath: string; docDiffs: NodeDiff[]; } @@ -124,10 +125,11 @@ export function diffHeaderFooters( const oldDoc = schema.nodeFromJSON(previousPart.content); const newDoc = schema.nodeFromJSON(nextPart.content); const docDiffs = diffNodes(normalizeNodes(oldDoc), normalizeNodes(newDoc)); - if (docDiffs.length > 0) { + if (docDiffs.length > 0 || previousPart.partPath !== nextPart.partPath) { modifiedParts.push({ refId: nextPart.refId, kind: nextPart.kind, + oldPartPath: previousPart.partPath, partPath: nextPart.partPath, docDiffs, }); diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 5491b6c9ba..6d2b17e95d 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -424,6 +424,64 @@ describe('Header/footer diffing', () => { } }); + it('treats header part path changes as a real diff', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedPart(beforeEditor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header1.xml', + text: 'Same header', + }); + setBodySection(beforeEditor, { headerDefault: 'rIdHeader1' }); + + seedPart(afterEditor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header2.xml', + text: 'Same header', + }); + setBodySection(afterEditor, { headerDefault: 'rIdHeader1' }); + + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(diff.headerFootersDiff?.modifiedParts).toHaveLength(1); + expect(diff.headerFootersDiff?.modifiedParts[0]).toMatchObject({ + refId: 'rIdHeader1', + oldPartPath: 'word/header1.xml', + partPath: 'word/header2.xml', + }); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(beforeEditor.converter?.convertedXml?.['word/header1.xml']).toBeUndefined(); + expect(beforeEditor.converter?.convertedXml?.['word/header2.xml']).toBeTruthy(); + + const relsRoot = ( + beforeEditor.converter?.convertedXml?.['word/_rels/document.xml.rels'] as + | { + elements?: Array<{ + name?: string; + elements?: Array<{ name?: string; attributes?: Record }>; + }>; + } + | undefined + )?.elements?.find((entry) => entry.name === 'Relationships'); + const relationship = relsRoot?.elements?.find((entry) => entry.name === 'Relationship'); + expect(relationship?.attributes?.Target).toBe('header2.xml'); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('compares and replays header removal', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index c6a18c3370..3e3a2de8cc 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -107,7 +107,15 @@ export function replayHeaderFooters({ } for (const part of headerFootersDiff.modifiedParts) { - const updated = applyHeaderFooterPartContent(editor.converter, schema, part.refId, part.kind, part.docDiffs); + const updated = applyHeaderFooterPartContent( + editor.converter, + schema, + part.refId, + part.kind, + part.oldPartPath, + part.partPath, + part.docDiffs, + ); if (updated) { result.applied += 1; continue; @@ -230,6 +238,8 @@ function applyHeaderFooterPartContent( schema: Schema, refId: string, kind: HeaderFooterKind, + oldPartPath: string, + partPath: string, docDiffs: import('../algorithm/generic-diffing').NodeDiff[], ): boolean { const collection = kind === 'header' ? converter.headers! : converter.footers!; @@ -238,22 +248,27 @@ function applyHeaderFooterPartContent( return false; } - const state = EditorState.create({ - schema, - doc: schema.nodeFromJSON(currentJson), - }); - const partTr = state.tr; - const replay = replayDocDiffs({ - tr: partTr, - docDiffs, - schema, - }); - if (replay.skipped > 0) { - return false; + let nextJson = currentJson; + if (docDiffs.length > 0) { + const state = EditorState.create({ + schema, + doc: schema.nodeFromJSON(currentJson), + }); + const partTr = state.tr; + const replay = replayDocDiffs({ + tr: partTr, + docDiffs, + schema, + }); + if (replay.skipped > 0) { + return false; + } + + nextJson = partTr.doc.toJSON(); + collection[refId] = nextJson; } - const nextJson = partTr.doc.toJSON(); - collection[refId] = nextJson; + updateHeaderFooterPartPath(converter, { refId, kind, oldPartPath, partPath }); syncHeaderFooterPartXml(converter, schema, kind, refId, nextJson); return true; } @@ -412,9 +427,51 @@ function deleteHeaderFooterPart( removeRelationshipEntry(converter.convertedXml!, part.refId); delete converter.convertedXml![part.partPath]; - const partFileName = part.partPath.split('/').pop(); - if (partFileName) { - delete converter.convertedXml![`word/_rels/${partFileName}.rels`]; + delete converter.convertedXml![toRelsPathForPart(part.partPath)]; +} + +function updateHeaderFooterPartPath( + converter: NonNullable, + part: { refId: string; kind: HeaderFooterKind; oldPartPath: string; partPath: string }, +): void { + upsertRelationshipEntry(converter.convertedXml!, { + refId: part.refId, + kind: part.kind, + partPath: part.partPath, + content: { type: 'doc', content: [] }, + }); + + if (part.oldPartPath === part.partPath) { + ensureXmlPartExists(converter.convertedXml!, { + refId: part.refId, + kind: part.kind, + partPath: part.partPath, + content: { type: 'doc', content: [] }, + }); + return; + } + + const previousXml = converter.convertedXml![part.oldPartPath]; + if (previousXml) { + converter.convertedXml![part.partPath] = previousXml; + delete converter.convertedXml![part.oldPartPath]; + } else { + ensureXmlPartExists(converter.convertedXml!, { + refId: part.refId, + kind: part.kind, + partPath: part.partPath, + content: { type: 'doc', content: [] }, + }); + } + + const oldRelsPath = toRelsPathForPart(part.oldPartPath); + const nextRelsPath = toRelsPathForPart(part.partPath); + if (oldRelsPath !== nextRelsPath) { + const previousRels = converter.convertedXml![oldRelsPath]; + if (previousRels) { + converter.convertedXml![nextRelsPath] = previousRels; + delete converter.convertedXml![oldRelsPath]; + } } } @@ -498,6 +555,11 @@ function ensureXmlPartExists(convertedXml: Record, part: Header }; } +function toRelsPathForPart(partPath: string): string { + const fileName = partPath.split('/').pop(); + return `word/_rels/${fileName}.rels`; +} + /** * Exports stored PM JSON content back into the OOXML XML part cache. * From 2785e298b4e4ae3aeb76169f149077a3656bfbe7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 23 Mar 2026 17:12:49 -0300 Subject: [PATCH 19/41] 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. --- packages/document-api/src/contract/schemas.ts | 5 -- packages/document-api/src/diff/diff.ts | 9 -- packages/document-api/src/diff/diff.types.ts | 5 -- .../extensions/diffing/headerFooters.test.ts | 4 +- .../diffing/service/diff-service.test.ts | 8 +- .../diffing/service/diff-service.ts | 86 ++----------------- 6 files changed, 11 insertions(+), 106 deletions(-) diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index ab3a417a19..cbd4ccd95d 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2804,7 +2804,6 @@ const diffSnapshotSchema: JsonSchema = objectSchema( version: { type: 'string', enum: ['sd-diff-snapshot/v1', 'sd-diff-snapshot/v2'] }, engine: { type: 'string', enum: ['super-editor'] }, fingerprint: { type: 'string' }, - partsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, payload: { type: 'object', description: 'Opaque engine-owned snapshot data.' }, }, @@ -2817,8 +2816,6 @@ const diffPayloadSchema: JsonSchema = objectSchema( engine: { type: 'string', enum: ['super-editor'] }, baseFingerprint: { type: 'string' }, targetFingerprint: { type: 'string' }, - basePartsFingerprint: { type: 'string' }, - targetPartsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, summary: diffSummarySchema, payload: { type: 'object', description: 'Opaque engine-owned diff data.' }, @@ -2831,8 +2828,6 @@ const diffApplyResultSchema: JsonSchema = objectSchema( appliedOperations: { type: 'integer' }, baseFingerprint: { type: 'string' }, targetFingerprint: { type: 'string' }, - basePartsFingerprint: { type: 'string' }, - targetPartsFingerprint: { type: 'string' }, coverage: diffCoverageSchema, summary: diffSummarySchema, diagnostics: { type: 'array', items: { type: 'string' } }, diff --git a/packages/document-api/src/diff/diff.ts b/packages/document-api/src/diff/diff.ts index 5d837029a4..55b8d31f2f 100644 --- a/packages/document-api/src/diff/diff.ts +++ b/packages/document-api/src/diff/diff.ts @@ -66,9 +66,6 @@ function validateSnapshotWrapper(snapshot: unknown): asserts snapshot is DiffSna if (typeof snapshot.fingerprint !== 'string') { throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.fingerprint must be a string.'); } - if (snapshot.version === 'sd-diff-snapshot/v2' && typeof snapshot.partsFingerprint !== 'string') { - throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.partsFingerprint must be a string.'); - } if (!isRecord(snapshot.coverage)) { throw new DocumentApiValidationError('INVALID_INPUT', 'targetSnapshot.coverage must be an object.'); } @@ -96,12 +93,6 @@ function validateDiffPayloadWrapper(diff: unknown): asserts diff is DiffPayload if (typeof diff.targetFingerprint !== 'string') { throw new DocumentApiValidationError('INVALID_INPUT', 'diff.targetFingerprint must be a string.'); } - if (diff.version === 'sd-diff-payload/v2' && typeof diff.basePartsFingerprint !== 'string') { - throw new DocumentApiValidationError('INVALID_INPUT', 'diff.basePartsFingerprint must be a string.'); - } - if (diff.version === 'sd-diff-payload/v2' && typeof diff.targetPartsFingerprint !== 'string') { - throw new DocumentApiValidationError('INVALID_INPUT', 'diff.targetPartsFingerprint must be a string.'); - } if (!isRecord(diff.coverage)) { throw new DocumentApiValidationError('INVALID_INPUT', 'diff.coverage must be an object.'); } diff --git a/packages/document-api/src/diff/diff.types.ts b/packages/document-api/src/diff/diff.types.ts index 5a47ebfbc3..e9f5e39780 100644 --- a/packages/document-api/src/diff/diff.types.ts +++ b/packages/document-api/src/diff/diff.types.ts @@ -35,7 +35,6 @@ export interface DiffSnapshot { version: 'sd-diff-snapshot/v1' | 'sd-diff-snapshot/v2'; engine: DiffEngineId; fingerprint: string; - partsFingerprint?: string; coverage: DiffCoverage; /** Opaque engine-owned snapshot data. Do not inspect or modify. */ payload: Record; @@ -63,8 +62,6 @@ export interface DiffPayload { engine: DiffEngineId; baseFingerprint: string; targetFingerprint: string; - basePartsFingerprint?: string; - targetPartsFingerprint?: string; coverage: DiffCoverage; summary: DiffSummary; /** Opaque engine-owned diff data. Do not inspect or modify. */ @@ -76,8 +73,6 @@ export interface DiffApplyResult { appliedOperations: number; baseFingerprint: string; targetFingerprint: string; - basePartsFingerprint?: string; - targetPartsFingerprint?: string; coverage: DiffCoverage; summary: DiffSummary; diagnostics: string[]; diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 6d2b17e95d..e8cd65c988 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -474,7 +474,9 @@ describe('Header/footer diffing', () => { } | undefined )?.elements?.find((entry) => entry.name === 'Relationships'); - const relationship = relsRoot?.elements?.find((entry) => entry.name === 'Relationship'); + const relationship = relsRoot?.elements?.find( + (entry) => entry.name === 'Relationship' && entry.attributes?.Id === 'rIdHeader1', + ); expect(relationship?.attributes?.Target).toBe('header2.xml'); } finally { beforeEditor.destroy?.(); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 9780f94780..736e0ee209 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -483,7 +483,6 @@ describe('diff-service tracked apply', () => { const diff = compareToSnapshot(baseEditor, snapshot); const baseSnapshot = captureSnapshot(baseEditor); expect(baseSnapshot.fingerprint).toBe(diff.baseFingerprint); - expect(baseSnapshot.partsFingerprint).toBe(diff.basePartsFingerprint); const relsPart = baseEditor.converter?.convertedXml?.['word/_rels/document.xml.rels'] as | { @@ -508,12 +507,9 @@ describe('diff-service tracked apply', () => { baseEditor.storage.image.media['word/media/unexpected-image.png'] = 'base64-unexpected'; const mutatedSnapshot = captureSnapshot(baseEditor); - expect(mutatedSnapshot.fingerprint).toBe(baseSnapshot.fingerprint); - expect(mutatedSnapshot.partsFingerprint).not.toBe(baseSnapshot.partsFingerprint); + expect(mutatedSnapshot.fingerprint).not.toBe(baseSnapshot.fingerprint); - expect(() => applyDiffPayload(baseEditor, diff, { changeMode: 'direct' })).toThrowError( - /parts fingerprint mismatch/i, - ); + expect(() => applyDiffPayload(baseEditor, diff, { changeMode: 'direct' })).toThrowError(/fingerprint mismatch/i); } finally { baseEditor.destroy?.(); targetEditor.destroy?.(); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index f4c8b2dac2..34224ef6d6 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -156,8 +156,7 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { const headerFooters = getEditorHeaderFooters(editor); const partsState = getEditorPartsState(editor, headerFooters); - const canonical = buildCanonicalStateForCoverage(doc, comments, styles, numbering, headerFooters, null, V2_COVERAGE); - const partsCanonical = buildCanonicalStateForCoverage( + const canonical = buildCanonicalStateForCoverage( doc, comments, styles, @@ -167,13 +166,11 @@ export function captureSnapshot(editor: DiffServiceEditor): DiffSnapshot { V2_COVERAGE, ); const fingerprint = computeFingerprint(canonical); - const partsFingerprint = computeFingerprint(partsCanonical); return { version: SNAPSHOT_VERSION_V2, engine: ENGINE_ID, fingerprint, - partsFingerprint, coverage: { ...V2_COVERAGE }, // Deep-clone every slot so the snapshot is immutable. doc.toJSON() // already returns a fresh tree; the rest are live references that would @@ -229,7 +226,7 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif targetStyles, targetNumbering, targetHeaderFooters, - null, + targetSnapshot.version === SNAPSHOT_VERSION_V2 ? targetPartsState : null, targetCoverage, ); reDerivedFingerprint = computeFingerprint(targetCanonical); @@ -246,26 +243,6 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif `Target snapshot fingerprint does not match re-derived value. The snapshot may have been tampered with.`, ); } - if (targetSnapshot.version === SNAPSHOT_VERSION_V2) { - const reDerivedPartsFingerprint = computeFingerprint( - buildCanonicalStateForCoverage( - targetDoc, - targetComments, - targetStyles, - targetNumbering, - targetHeaderFooters, - targetPartsState, - targetCoverage, - ), - ); - if (reDerivedPartsFingerprint !== targetSnapshot.partsFingerprint) { - throw new DiffServiceError( - 'INVALID_INPUT', - `Target snapshot parts fingerprint does not match re-derived value. The snapshot may have been tampered with.`, - ); - } - } - // Compute base fingerprint const baseDoc = editor.state.doc; const baseComments = getEditorComments(editor); @@ -279,24 +256,10 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif baseStyles, baseNumbering, baseHeaderFooters, - null, + targetSnapshot.version === SNAPSHOT_VERSION_V2 ? basePartsState : null, targetCoverage, ); const baseFingerprint = computeFingerprint(baseCanonical); - const basePartsFingerprint = - targetSnapshot.version === SNAPSHOT_VERSION_V2 - ? computeFingerprint( - buildCanonicalStateForCoverage( - baseDoc, - baseComments, - baseStyles, - baseNumbering, - baseHeaderFooters, - basePartsState, - targetCoverage, - ), - ) - : null; // Compute raw diff. Wrap in try-catch so malformed nested comment bodies // (e.g. textJson that passes structural validation but fails inside @@ -341,14 +304,12 @@ export function compareToSnapshot(editor: DiffServiceEditor, targetSnapshot: Dif engine: ENGINE_ID, baseFingerprint, targetFingerprint: targetSnapshot.fingerprint, - basePartsFingerprint: basePartsFingerprint ?? undefined, - targetPartsFingerprint: targetSnapshot.partsFingerprint, coverage: { ...targetCoverage }, summary, // Detach the payload from editor-owned objects before returning it across // the API boundary. Comment diffs can otherwise retain live comment refs. payload, - }; + } as DiffPayload; } // --------------------------------------------------------------------------- @@ -393,7 +354,7 @@ export function applyDiffPayload( baseStyles, baseNumbering, baseHeaderFooters, - null, + diffPayload.version === PAYLOAD_VERSION_V2 ? basePartsState : null, diffPayload.coverage, ); const currentFingerprint = computeFingerprint(baseCanonical); @@ -405,27 +366,6 @@ export function applyDiffPayload( `The document may have changed since the diff was computed. Re-run diff.compare against the current state.`, ); } - if (diffPayload.version === PAYLOAD_VERSION_V2) { - const currentPartsFingerprint = computeFingerprint( - buildCanonicalStateForCoverage( - baseDoc, - baseComments, - baseStyles, - baseNumbering, - baseHeaderFooters, - basePartsState, - diffPayload.coverage, - ), - ); - if (currentPartsFingerprint !== diffPayload.basePartsFingerprint) { - throw new DiffServiceError( - 'PRECONDITION_FAILED', - `Document parts fingerprint mismatch. Expected "${diffPayload.basePartsFingerprint}", got "${currentPartsFingerprint}". ` + - `The document's part/media state may have changed since the diff was computed. Re-run diff.compare against the current state.`, - ); - } - } - // Reconstruct internal DiffResult from opaque payload with structural validation const rawDiff = parseDiffPayloadContents(diffPayload.payload); @@ -498,12 +438,10 @@ export function applyDiffPayload( appliedOperations: replayResult.appliedDiffs, baseFingerprint: diffPayload.baseFingerprint, targetFingerprint: diffPayload.targetFingerprint, - basePartsFingerprint: diffPayload.basePartsFingerprint, - targetPartsFingerprint: diffPayload.targetPartsFingerprint, coverage: { ...diffPayload.coverage }, summary: verifiedSummary, diagnostics: replayResult.warnings, - }, + } as DiffApplyResult, tr, }; } @@ -819,24 +757,12 @@ function validateSnapshotFingerprints(snapshot: DiffSnapshot): void { if (typeof snapshot.fingerprint !== 'string') { throw new DiffServiceError('INVALID_INPUT', 'Snapshot fingerprint must be a string.'); } - if (snapshot.version === SNAPSHOT_VERSION_V2 && typeof snapshot.partsFingerprint !== 'string') { - throw new DiffServiceError('INVALID_INPUT', 'Snapshot partsFingerprint must be a string for v2 snapshots.'); - } } function validatePayloadFingerprints(payload: DiffPayload): void { if (typeof payload.baseFingerprint !== 'string' || typeof payload.targetFingerprint !== 'string') { throw new DiffServiceError('INVALID_INPUT', 'Diff payload fingerprints must be strings.'); } - if ( - payload.version === PAYLOAD_VERSION_V2 && - (typeof payload.basePartsFingerprint !== 'string' || typeof payload.targetPartsFingerprint !== 'string') - ) { - throw new DiffServiceError( - 'INVALID_INPUT', - 'Diff payload basePartsFingerprint and targetPartsFingerprint must be strings for v2 payloads.', - ); - } } function validateSnapshotPayload(payload: Record): void { From 30bfa3a2d823ccaef122a9433bca0a4148fdd680 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 13:57:55 -0300 Subject: [PATCH 20/41] 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. --- .../diffing/algorithm/parts-diffing.test.ts | 74 +++++++++++++++- .../diffing/algorithm/parts-diffing.ts | 88 ++++++++++--------- .../extensions/diffing/headerFooters.test.ts | 2 +- 3 files changed, 122 insertions(+), 42 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts index e977f5ceaa..52b9636e90 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { capturePartsState } from './parts-diffing'; +import { capturePartsState, diffParts } from './parts-diffing'; describe('parts-diffing', () => { it('captures nested relationship parts relative to the part directory', () => { @@ -67,4 +67,76 @@ describe('parts-diffing', () => { content: 'base64-embedded-workbook', }); }); + + it('diffs asset-only body changes even when semantic diffs are empty', () => { + const previousPartsState = { + bodyClosure: { + 'word/_rels/document.xml.rels': { + kind: 'xml', + content: { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rIdImage1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/image1.png', + }, + }, + ], + }, + ], + }, + }, + 'word/media/image1.png': { + kind: 'binary', + content: 'base64-old-image', + }, + }, + headerFooterClosures: {}, + }; + + const nextPartsState = { + bodyClosure: { + 'word/_rels/document.xml.rels': { + kind: 'xml', + content: { + elements: [ + { + name: 'Relationships', + elements: [ + { + name: 'Relationship', + attributes: { + Id: 'rIdImage1', + Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + Target: 'media/image1.png', + }, + }, + ], + }, + ], + }, + }, + 'word/media/image1.png': { + kind: 'binary', + content: 'base64-new-image', + }, + }, + headerFooterClosures: {}, + }; + + const partsDiff = diffParts([], null, previousPartsState, nextPartsState); + + expect(partsDiff).not.toBeNull(); + expect(partsDiff?.upserts['word/media/image1.png']).toEqual({ + kind: 'binary', + content: 'base64-new-image', + }); + expect(partsDiff?.upserts['word/document.xml']).toBeUndefined(); + expect(partsDiff?.deletes).toEqual([]); + }); }); diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index c3e29cd6ab..2200d538e0 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -90,53 +90,26 @@ export function diffParts( previousPartsState: PartsState | null | undefined, nextPartsState: PartsState | null | undefined, ): PartsDiff | null { + if (!previousPartsState || !nextPartsState) { + return null; + } + const upserts: Record = {}; const deletes = new Set(); const nextReachablePartPaths = collectReachablePartPaths(nextPartsState); + const previousOwnedParts = collectOwnedParts(previousPartsState); + const nextOwnedParts = collectOwnedParts(nextPartsState); - if (docDiffs.length > 0) { - for (const [partPath, snapshot] of Object.entries(nextPartsState?.bodyClosure ?? {})) { - const previous = previousPartsState?.bodyClosure?.[partPath]; - if (!previous || !partSnapshotsEqual(previous, snapshot)) { - upserts[partPath] = structuredClone(snapshot); - } - } - - for (const partPath of Object.keys(previousPartsState?.bodyClosure ?? {})) { - if (!(partPath in (nextPartsState?.bodyClosure ?? {})) && !(partPath in upserts)) { - deletes.add(partPath); - } + for (const [partPath, snapshot] of Object.entries(nextOwnedParts)) { + const previous = previousOwnedParts[partPath]; + if (!previous || !partSnapshotsEqual(previous, snapshot)) { + upserts[partPath] = structuredClone(snapshot); } } - if (headerFootersDiff) { - for (const part of [...headerFootersDiff.addedParts, ...headerFootersDiff.modifiedParts]) { - const closure = nextPartsState?.headerFooterClosures?.[part.refId]; - if (!closure) continue; - for (const [partPath, snapshot] of Object.entries(closure.parts)) { - upserts[partPath] = structuredClone(snapshot); - deletes.delete(partPath); - } - } - - for (const part of headerFootersDiff.removedParts) { - const closure = previousPartsState?.headerFooterClosures?.[part.refId]; - if (closure) { - for (const partPath of Object.keys(closure.parts)) { - if (!(partPath in upserts) && !nextReachablePartPaths.has(partPath)) { - deletes.add(partPath); - } - } - continue; - } - - if (!nextReachablePartPaths.has(part.partPath)) { - deletes.add(part.partPath); - } - const relsPath = toRelsPathForPart(part.partPath); - if (relsPath && !nextReachablePartPaths.has(relsPath)) { - deletes.add(relsPath); - } + for (const partPath of Object.keys(previousOwnedParts)) { + if (!(partPath in nextOwnedParts) && !(partPath in upserts) && !nextReachablePartPaths.has(partPath)) { + deletes.add(partPath); } } @@ -150,6 +123,41 @@ export function diffParts( }; } +function collectOwnedParts(partsState: PartsState): Record { + const owned: Record = {}; + + addOwnedPartsFromClosure(owned, partsState.bodyClosure); + + for (const closure of Object.values(partsState.headerFooterClosures)) { + addOwnedPartsFromClosure(owned, closure.parts, closure.partPath); + } + + return owned; +} + +function addOwnedPartsFromClosure( + target: Record, + closure: Record, + semanticRootPath?: string, +): void { + for (const [partPath, snapshot] of Object.entries(closure)) { + if (isSemanticOwnedPart(partPath, semanticRootPath)) { + continue; + } + target[partPath] = snapshot; + } +} + +function isSemanticOwnedPart(partPath: string, headerFooterRootPath?: string): boolean { + if (partPath === 'word/document.xml' || partPath === 'word/styles.xml' || partPath === 'word/numbering.xml') { + return true; + } + if (headerFooterRootPath && partPath === headerFooterRootPath) { + return true; + } + return false; +} + function collectReachablePartPaths(partsState: PartsState | null | undefined): Set { const reachable = new Set(); diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index e8cd65c988..dc9a8113b5 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -575,7 +575,7 @@ describe('Header/footer diffing', () => { expect(diff.headerFootersDiff?.removedParts).toHaveLength(1); expect(diff.partsDiff?.deletes).not.toContain('word/media/shared-logo.png'); - expect(diff.partsDiff?.deletes).toContain('word/header2.xml'); + expect(diff.partsDiff?.deletes).not.toContain('word/header2.xml'); expect(diff.partsDiff?.deletes).toContain('word/_rels/header2.xml.rels'); } finally { beforeEditor.destroy?.(); From 6d4f1f714bb545ee307fc81c8e0bad2aa2ed3929 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 14:06:33 -0300 Subject: [PATCH 21/41] 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. --- .../extensions/diffing/headerFooters.test.ts | 41 +++++++++++++++++++ .../extensions/diffing/replay/replay-parts.ts | 19 +++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index dc9a8113b5..754d2d96ad 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -370,6 +370,47 @@ describe('Header/footer diffing', () => { } }); + it('emits partChanged for parts replayed from diff payloads', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultHeader(afterEditor, 'Header with image'); + seedPartDependency(afterEditor, { + partPath: 'word/header1.xml', + relationshipId: 'rIdImage1', + target: 'media/header-logo.png', + targetPath: 'word/media/header-logo.png', + mediaContent: 'data:image/png;base64,aGVhZGVy', + }); + + const emitSpy = vi.spyOn(beforeEditor, 'emit'); + const diff = beforeEditor.commands.compareDocuments( + afterEditor.state.doc, + afterEditor.converter?.comments ?? [], + afterEditor.converter?.translatedLinkedStyles, + afterEditor.converter?.translatedNumbering, + afterEditor, + ); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + 'partChanged', + expect.objectContaining({ + source: 'diff-replay', + parts: expect.arrayContaining([ + expect.objectContaining({ partId: 'word/_rels/header1.xml.rels' }), + expect.objectContaining({ partId: 'word/media/header-logo.png' }), + ]), + }), + ); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('exports a valid header part after replay adds a new header', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts index f2ba05f3df..b5ac4e25fe 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -2,6 +2,7 @@ import { ReplayResult } from './replay-types'; import type { PartsDiff } from '../algorithm/parts-diffing'; type ReplayPartsEditor = { + emit?: (event: string, payload?: unknown) => void; options?: { mediaFiles?: Record; }; @@ -49,14 +50,26 @@ export function replayPartsDiff({ (editor.options ??= {}).mediaFiles ?? ((editor.options.mediaFiles = {}), editor.options.mediaFiles); const storageImage = (editor.storage ??= {}).image ?? ((editor.storage.image = {}), editor.storage.image); const storageMediaStore = storageImage.media ?? ((storageImage.media = {}), storageImage.media); + const changedParts: Array<{ + partId: string; + operation: 'mutate' | 'create' | 'delete'; + changedPaths: string[]; + }> = []; for (const [partPath, snapshot] of Object.entries(partsDiff.upserts)) { if (snapshot.kind === 'xml') { + const operation = partPath in editor.converter.convertedXml ? 'mutate' : 'create'; editor.converter.convertedXml[partPath] = structuredClone(snapshot.content); + changedParts.push({ partId: partPath, operation, changedPaths: [] }); } else { + const operation = + partPath in optionMediaStore || partPath in storageMediaStore || partPath in editor.converter.convertedXml + ? 'mutate' + : 'create'; const value = structuredClone(snapshot.content); optionMediaStore[partPath] = value; storageMediaStore[partPath] = structuredClone(value); + changedParts.push({ partId: partPath, operation, changedPaths: [] }); } result.applied += 1; } @@ -64,6 +77,7 @@ export function replayPartsDiff({ for (const partPath of partsDiff.deletes) { if (partPath in editor.converter.convertedXml) { delete editor.converter.convertedXml[partPath]; + changedParts.push({ partId: partPath, operation: 'delete', changedPaths: [] }); result.applied += 1; continue; } @@ -76,10 +90,15 @@ export function replayPartsDiff({ delete storageMediaStore[partPath]; } if (hadOptionMedia || hadStorageMedia) { + changedParts.push({ partId: partPath, operation: 'delete', changedPaths: [] }); result.applied += 1; } } + if (changedParts.length > 0) { + editor.emit?.('partChanged', { parts: changedParts, source: 'diff-replay' }); + } + return { applied: result.applied, skipped: result.skipped, From 8eb3b93d885b23bad1440c5cc10dfb6f5b1036fb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 14:14:10 -0300 Subject: [PATCH 22/41] 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. --- .../src/extensions/diffing/diffing.js | 29 ++---- .../extensions/diffing/headerFooters.test.ts | 88 +++---------------- .../extensions/diffing/replayDiffs.test.js | 52 ++++------- .../src/dev/components/SuperdocDev.vue | 12 +-- 4 files changed, 38 insertions(+), 143 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/diffing.js b/packages/super-editor/src/extensions/diffing/diffing.js index 964313c71e..b226859e4f 100644 --- a/packages/super-editor/src/extensions/diffing/diffing.js +++ b/packages/super-editor/src/extensions/diffing/diffing.js @@ -11,42 +11,31 @@ export const Diffing = Extension.create({ addCommands() { return { /** - * Compares the current document against `updatedDocument` and returns the diffs required to + * Compares the current document against `targetEditor` and returns the diffs required to * transform the former into the latter. * * These diffs are intended to be replayed on-top of the old document, so apply the * returned list in reverse (last entry first) to keep insertions that share the same * `pos` anchor in the correct order. * - * @param {import('prosemirror-model').Node} updatedDocument - * @param {import('./algorithm/comment-diffing.ts').CommentInput[]} [updatedComments] - * @param {import('@superdoc/style-engine/ooxml').StylesDocumentProperties | null} [updatedStyles] - * @param {import('@superdoc/style-engine/ooxml').NumberingProperties | null} [updatedNumbering] - * @param {import('./algorithm/header-footer-diffing.ts').HeaderFooterState | { state?: unknown; converter?: unknown } | null} [updatedHeaderFooters] + * @param {{ state: { doc: import('prosemirror-model').Node; schema: import('prosemirror-model').Schema }; converter?: unknown }} targetEditor * @returns {import('./computeDiff.ts').DiffResult} */ compareDocuments: - (updatedDocument, updatedComments, updatedStyles, updatedNumbering, updatedHeaderFooters) => + (targetEditor) => ({ state, tr }) => { tr.setMeta('preventDispatch', true); + const updatedDocument = targetEditor.state.doc; const currentComments = this.editor.converter?.comments ?? []; - const nextComments = updatedComments === undefined ? currentComments : updatedComments; + const nextComments = targetEditor.converter?.comments ?? currentComments; const currentStyles = this.editor.converter?.translatedLinkedStyles ?? null; - const nextStyles = updatedStyles === undefined ? currentStyles : updatedStyles; + const nextStyles = targetEditor.converter?.translatedLinkedStyles ?? currentStyles; const currentNumbering = this.editor.converter?.translatedNumbering ?? null; - const nextNumbering = updatedNumbering === undefined ? currentNumbering : updatedNumbering; + const nextNumbering = targetEditor.converter?.translatedNumbering ?? currentNumbering; const currentHeaderFooters = captureHeaderFooterState(this.editor); const currentPartsState = capturePartsState(this.editor, currentHeaderFooters); - const nextHeaderFooters = - updatedHeaderFooters === undefined - ? currentHeaderFooters - : updatedHeaderFooters?.state && updatedHeaderFooters?.converter - ? captureHeaderFooterState(updatedHeaderFooters) - : updatedHeaderFooters; - const nextPartsState = - updatedHeaderFooters?.state && updatedHeaderFooters?.converter - ? capturePartsState(updatedHeaderFooters, nextHeaderFooters) - : null; + const nextHeaderFooters = captureHeaderFooterState(targetEditor); + const nextPartsState = capturePartsState(targetEditor, nextHeaderFooters); const diffs = computeDiff( state.doc, updatedDocument, diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 754d2d96ad..824511e658 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -280,13 +280,7 @@ describe('Header/footer diffing', () => { setBodySection(beforeEditor, {}); seedDefaultHeader(afterEditor, 'New header'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.headerFootersDiff?.addedParts).toHaveLength(1); expect(diff.headerFootersDiff?.slotChanges).toHaveLength(1); @@ -308,13 +302,7 @@ describe('Header/footer diffing', () => { seedDefaultHeader(afterEditor, 'New header'); const emitSpy = vi.spyOn(beforeEditor, 'emit'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); expect(emitSpy).toHaveBeenCalledWith( @@ -347,13 +335,7 @@ describe('Header/footer diffing', () => { mediaContent: 'data:image/png;base64,aGVhZGVy', }); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.partsDiff).not.toBeNull(); expect(diff.partsDiff?.upserts['word/_rels/header1.xml.rels']).toBeTruthy(); @@ -386,13 +368,7 @@ describe('Header/footer diffing', () => { }); const emitSpy = vi.spyOn(beforeEditor, 'emit'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); expect(emitSpy).toHaveBeenCalledWith( @@ -419,13 +395,7 @@ describe('Header/footer diffing', () => { setBodySection(beforeEditor, {}); seedDefaultHeader(afterEditor, 'Exported header'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); @@ -447,13 +417,7 @@ describe('Header/footer diffing', () => { seedDefaultHeader(beforeEditor, 'Old header'); seedDefaultHeader(afterEditor, 'Updated header'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.headerFootersDiff?.modifiedParts).toHaveLength(1); @@ -486,13 +450,7 @@ describe('Header/footer diffing', () => { }); setBodySection(afterEditor, { headerDefault: 'rIdHeader1' }); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.headerFootersDiff?.modifiedParts).toHaveLength(1); expect(diff.headerFootersDiff?.modifiedParts[0]).toMatchObject({ @@ -533,13 +491,7 @@ describe('Header/footer diffing', () => { seedDefaultHeader(beforeEditor, 'Remove me'); setBodySection(afterEditor, {}); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.headerFootersDiff?.removedParts).toHaveLength(1); expect(diff.headerFootersDiff?.slotChanges).toHaveLength(1); @@ -606,13 +558,7 @@ describe('Header/footer diffing', () => { headerDefault: 'rIdHeaderDefault', }); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.headerFootersDiff?.removedParts).toHaveLength(1); expect(diff.partsDiff?.deletes).not.toContain('word/media/shared-logo.png'); @@ -634,13 +580,7 @@ describe('Header/footer diffing', () => { afterEditor.dispatch(afterEditor.state.tr.insertText('Updated ', 1)); seedDefaultHeader(afterEditor, 'Tracked header'); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true })).toBe(true); expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); @@ -661,13 +601,7 @@ describe('Header/footer diffing', () => { seedDefaultHeader(afterEditor, 'Default header'); setBodySection(afterEditor, { titlePg: true, headerDefault: 'rIdHeader1' }); - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(beforeEditor.converter?.headerIds?.titlePg).not.toBe(true); diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js index d78d80c21e..efb3f9f7e3 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js @@ -78,7 +78,7 @@ const expectReplayMatchesFixture = async (beforeName, afterName) => { try { const originalDocJSON = beforeEditor.state.doc.toJSON(); - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false }); expect(success).toBe(true); @@ -106,13 +106,7 @@ const expectDirectReplayPopulatesBodyMedia = async (beforeName, afterName, apply const afterEditor = await getEditorFromFixture(afterName); try { - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - afterEditor, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const mediaUpserts = Object.keys(diff.partsDiff?.upserts ?? {}).filter((path) => path.startsWith('word/media/')); expect(mediaUpserts.length).toBeGreaterThan(0); @@ -145,7 +139,7 @@ const expectReplaySkipsTrackingWhenDisabled = async (beforeName, afterName) => { try { expect(beforeEditor.commands.enableTrackChanges()).toBe(true); - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false }); expect(success).toBe(true); @@ -169,7 +163,7 @@ const expectReplayMatchesFixtureWithDefaultOptions = async (beforeName, afterNam const afterEditor = await getEditorFromFixture(afterName); try { - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff); expect(success).toBe(true); @@ -196,7 +190,7 @@ const expectReplayCanHasNoSideEffects = async (beforeName, afterName) => { const originalCommentsJSON = JSON.parse(JSON.stringify(beforeEditor.converter?.comments ?? [])); const emitSpy = vi.spyOn(beforeEditor, 'emit'); - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const canReplay = beforeEditor.can().replayDifferences(diff); expect(canReplay).toBe(true); @@ -222,7 +216,7 @@ const expectTrackedReplayMatchesFixture = async (beforeName, afterName) => { try { const originalDocJSON = beforeEditor.state.doc.toJSON(); - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); expect(success).toBe(true); @@ -256,7 +250,7 @@ const expectTrackedReplayMarksHaveIds = async (beforeName, afterName) => { const afterEditor = await getEditorFromFixture(afterName); try { - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); expect(success).toBe(true); @@ -335,7 +329,7 @@ const expectReplayPreservesTableStyle = async (beforeName, afterName, applyTrack const afterEditor = await getEditorFromFixture(afterName); try { - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges }); expect(success).toBe(true); @@ -437,22 +431,15 @@ describe('replayDifferences options', () => { await expectReplaySkipsTrackingWhenDisabled('diff_before3.docx', 'diff_after3.docx'); }); }); -describe('compareDocuments defaults', () => { - it('does not emit comment delete diffs when updatedComments is omitted', async () => { +describe('compareDocuments', () => { + it('derives comments from the target editor without dispatch side effects', async () => { const beforeEditor = await getEditorFromFixture('diff_before8.docx'); const afterEditor = await getEditorFromFixture('diff_after8.docx'); try { const emitSpy = vi.spyOn(beforeEditor, 'emit'); - const omittedCommentsDiff = beforeEditor.commands.compareDocuments(afterEditor.state.doc); - expect(omittedCommentsDiff.commentDiffs).toHaveLength(0); - expect(emitSpy).not.toHaveBeenCalledWith('transaction', expect.anything()); - - const explicitCommentsDiff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - ); - expect(explicitCommentsDiff.commentDiffs.length).toBeGreaterThan(0); + const diff = beforeEditor.commands.compareDocuments(afterEditor); + expect(diff.commentDiffs.length).toBeGreaterThan(0); expect(emitSpy).not.toHaveBeenCalledWith('transaction', expect.anything()); } finally { beforeEditor.destroy?.(); @@ -489,7 +476,7 @@ describe('replayDiffs tracked append regression', () => { ); try { - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: true }); expect(success).toBe(true); @@ -513,7 +500,7 @@ describe('investigate replay issues', () => { try { const originalDocJSON = beforeEditor.state.doc.toJSON(); - const diff = beforeEditor.commands.compareDocuments(afterEditor.state.doc, afterEditor.converter?.comments ?? []); + const diff = beforeEditor.commands.compareDocuments(afterEditor); const success = beforeEditor.commands.replayDifferences(diff, { user: { user: { name: 'Test User', email: 'test@example.com' }, applyTrackedChanges: true }, }); @@ -536,20 +523,15 @@ describe('investigate replay issues', () => { }); describe('parts-aware replay', () => { - it('does not emit partsDiff for legacy compareDocuments callers without a compare editor', async () => { + it('captures partsDiff when comparing against a target editor', async () => { const beforeEditor = await getEditorFromFixture('diff_before19.docx'); const afterEditor = await getEditorFromFixture('diff_after19.docx'); try { - const diff = beforeEditor.commands.compareDocuments( - afterEditor.state.doc, - afterEditor.converter?.comments ?? [], - afterEditor.converter?.translatedLinkedStyles, - afterEditor.converter?.translatedNumbering, - ); + const diff = beforeEditor.commands.compareDocuments(afterEditor); expect(diff.docDiffs.length).toBeGreaterThan(0); - expect(diff.partsDiff).toBeNull(); + expect(diff.partsDiff).not.toBeNull(); } finally { beforeEditor.destroy?.(); afterEditor.destroy?.(); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index f1efdd375a..65fed929dd 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -291,17 +291,7 @@ const handleCompareFile = async (event) => { annotations: true, }); - const compareDoc = compareEditor.state.doc; - const compareComments = compareEditor.converter?.comments ?? []; - const compareTranslatedLinkedStyles = compareEditor.converter?.translatedLinkedStyles; - const compareTranslatedNumbering = compareEditor.converter?.translatedNumbering; - const diff = editor.commands.compareDocuments( - compareDoc, - compareComments, - compareTranslatedLinkedStyles, - compareTranslatedNumbering, - compareEditor, - ); + const diff = editor.commands.compareDocuments(compareEditor); const userToApply = editor.options?.user ?? user; editor.commands.replayDifferences(diff, { user: userToApply, applyTrackedChanges: true }); } finally { From 81c9447405f7dead3c94c8b9a8f401d3e5031c99 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 14:22:41 -0300 Subject: [PATCH 23/41] 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`. --- packages/super-editor/src/core/Editor.ts | 10 +++--- .../diffing/algorithm/parts-diffing.ts | 35 ++++++++++++++++++- .../diffing/replay/replay-header-footers.ts | 20 +++++++---- .../src/extensions/diffing/replayDiffs.ts | 2 ++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 4461e65bbf..0293d5d6dd 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -100,12 +100,14 @@ const MAX_WIDTH_BUFFER_PX = 20; type ExtensionInstanceLike = { type?: string; config?: Record; - constructor?: new (config: Record) => unknown; }; -const cloneExtensionInstance = (extension: T): T => { - const config = extension?.config; - const ExtensionCtor = extension?.constructor; +const cloneExtensionInstance = (extension: T): T => { + const extensionLike = extension as ExtensionInstanceLike & { + constructor?: new (config: Record) => unknown; + }; + const config = extensionLike?.config; + const ExtensionCtor = extensionLike?.constructor; if (!config || typeof config !== 'object' || typeof ExtensionCtor !== 'function') { return extension; diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 2200d538e0..0dff7c2962 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -1,4 +1,3 @@ -import { resolveOpcTargetPath } from '../../../core/super-converter/helpers.js'; import type { HeaderFooterKind, HeaderFooterState, HeaderFootersDiff } from './header-footer-diffing'; export interface PartSnapshot { @@ -51,6 +50,40 @@ export type PartsStateEditor = { const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels'; +/** + * Resolves an OOXML relationship target using OPC (Open Packaging Conventions) + * package-path rules relative to the given part directory. + */ +function resolveOpcTargetPath(target: string, baseDir = 'word'): string | null { + if (!target || typeof target !== 'string') { + return null; + } + + const normalizedBaseDir = baseDir.replace(/^\/+|\/+$/g, ''); + const trimmedTarget = target.trim(); + if (!trimmedTarget) { + return null; + } + + if (trimmedTarget.startsWith('/')) { + return trimmedTarget.replace(/^\/+/, ''); + } + + const segments = normalizedBaseDir ? normalizedBaseDir.split('/').filter(Boolean) : []; + for (const segment of trimmedTarget.split('/')) { + if (!segment || segment === '.') { + continue; + } + if (segment === '..') { + segments.pop(); + continue; + } + segments.push(segment); + } + + return segments.join('/'); +} + /** * Captures the body and header/footer part closures needed for coarse * parts-aware replay. diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 3e3a2de8cc..1e00273d06 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -50,6 +50,17 @@ type ReplayHeaderFooterEditor = { } | null; }; +type HeaderFooterVariantIds = Record & { + ids?: string[]; +}; + +type RelationshipElement = { + type?: string; + name?: string; + attributes?: Record; + elements?: RelationshipElement[]; +}; + const HEADER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; const FOOTER_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; @@ -210,7 +221,7 @@ function createHeaderFooterPart( const partCollection = part.kind === 'header' ? converter.headers! : converter.footers!; partCollection[part.refId] = structuredClone(part.content); - const variantIds = part.kind === 'header' ? converter.headerIds! : converter.footerIds!; + const variantIds = (part.kind === 'header' ? converter.headerIds! : converter.footerIds!) as HeaderFooterVariantIds; if (!Array.isArray(variantIds.ids)) { variantIds.ids = []; } @@ -415,7 +426,7 @@ function deleteHeaderFooterPart( const collection = part.kind === 'header' ? converter.headers! : converter.footers!; delete collection[part.refId]; - const variantIds = part.kind === 'header' ? converter.headerIds! : converter.footerIds!; + const variantIds = (part.kind === 'header' ? converter.headerIds! : converter.footerIds!) as HeaderFooterVariantIds; if (Array.isArray(variantIds.ids)) { variantIds.ids = variantIds.ids.filter((value) => value !== part.refId); } @@ -500,7 +511,6 @@ function upsertRelationshipEntry(convertedXml: Record, part: He } relsRoot.elements!.push({ - type: 'element', name: 'Relationship', attributes: { Id: part.refId, @@ -518,9 +528,7 @@ function upsertRelationshipEntry(convertedXml: Record, part: He * @param refId Relationship id to remove. */ function removeRelationshipEntry(convertedXml: Record, refId: string): void { - const relsPart = convertedXml['word/_rels/document.xml.rels'] as - | { elements?: Array<{ name?: string; elements?: Array<{ name?: string; attributes?: Record }> }> } - | undefined; + const relsPart = convertedXml['word/_rels/document.xml.rels'] as { elements?: RelationshipElement[] } | undefined; const relsRoot = relsPart?.elements?.find((entry) => entry.name === 'Relationships'); if (!relsRoot?.elements) { return; diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.ts b/packages/super-editor/src/extensions/diffing/replayDiffs.ts index dbf52dab41..aee1635626 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.ts +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.ts @@ -26,9 +26,11 @@ type ReplayDiffsParams = { schema: import('prosemirror-model').Schema; comments?: import('./algorithm/comment-diffing').CommentInput[]; editor?: { + state: { doc: import('prosemirror-model').Node }; emit?: (event: string, payload: unknown) => void; options?: { documentId?: string | null; + mediaFiles?: Record; }; converter?: { translatedLinkedStyles?: { From 8f781b610a25666ef8f18f57e2f44b6d99c01cfc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 14:40:11 -0300 Subject: [PATCH 24/41] 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. --- .../extensions/diffing/headerFooters.test.ts | 47 +++++++++++++++++++ .../diffing/replay/replay-header-footers.ts | 20 ++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 824511e658..4683b2a3a3 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -4,6 +4,8 @@ import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { captureHeaderFooterState } from './algorithm/header-footer-diffing'; +import { replayHeaderFooters } from './replay/replay-header-footers'; +import { resolveSectionProjections } from '../../document-api-adapters/helpers/sections-resolver.js'; /** * Creates a headless editor from a DOCX fixture. @@ -614,4 +616,49 @@ describe('Header/footer diffing', () => { afterEditor.destroy?.(); } }); + + it('updates converter variant ids when replay repoints a section header ref', async () => { + const beforeEditor = await createEditor(); + + try { + seedPart(beforeEditor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header1.xml', + text: 'Original default header', + }); + setBodySection(beforeEditor, { headerDefault: 'rIdHeader1' }); + beforeEditor.converter!.headerIds = { + ...(beforeEditor.converter!.headerIds ?? {}), + default: 'rIdHeader1', + ids: ['rIdHeader1'], + }; + const sectionId = resolveSectionProjections(beforeEditor as never)[0]?.sectionId; + expect(sectionId).toBeTruthy(); + + const tr = beforeEditor.state.tr; + replayHeaderFooters({ + tr, + schema: beforeEditor.schema, + editor: beforeEditor, + headerFootersDiff: { + addedParts: [], + modifiedParts: [], + removedParts: [], + slotChanges: [ + { + sectionId, + titlePg: false, + header: { default: 'rIdHeader2', first: null, even: null, odd: null }, + footer: { default: null, first: null, even: null, odd: null }, + }, + ], + }, + }); + + expect(beforeEditor.converter?.headerIds?.default).toBe('rIdHeader2'); + } finally { + beforeEditor.destroy?.(); + } + }); }); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 1e00273d06..f8a8b9b23e 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -317,6 +317,7 @@ function applyHeaderFooterSlotChange( applySlotRefs(nextSectPr, 'header', slot.header); applySlotRefs(nextSectPr, 'footer', slot.footer); + syncVariantIdCaches(editor.converter!, slot); if (projection.target.kind === 'paragraph') { const paragraph = tr.doc.nodeAt(projection.target.pos); @@ -363,6 +364,25 @@ function applySlotRefs( } } +/** + * Keeps converter variant-id caches aligned with the applied section slot refs. + * + * @param converter Converter object mutated during replay. + * @param slot Slot payload that was written into the section properties. + */ +function syncVariantIdCaches( + converter: NonNullable, + slot: HeaderFooterSlotState, +): void { + const headerIds = (converter.headerIds ??= {}) as HeaderFooterVariantIds; + const footerIds = (converter.footerIds ??= {}) as HeaderFooterVariantIds; + + for (const variant of SLOT_VARIANTS) { + headerIds[variant] = slot.header[variant] ?? null; + footerIds[variant] = slot.footer[variant] ?? null; + } +} + /** * Keeps converter body section caches aligned with body sectPr transaction changes. * From d9704a328359af308f95be93079585737f2f2e5d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 15:20:33 -0300 Subject: [PATCH 25/41] 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. --- .../extensions/diffing/headerFooters.test.ts | 41 +++++++++++++++++++ .../diffing/replay/replay-header-footers.ts | 5 +++ 2 files changed, 46 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 4683b2a3a3..003e3f3ac1 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -485,6 +485,47 @@ describe('Header/footer diffing', () => { } }); + it('emits delete and create partChanged events when a header part path is renamed', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + seedPart(beforeEditor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header1.xml', + text: 'Same header', + }); + setBodySection(beforeEditor, { headerDefault: 'rIdHeader1' }); + + seedPart(afterEditor, { + kind: 'header', + refId: 'rIdHeader1', + partPath: 'word/header2.xml', + text: 'Same header', + }); + setBodySection(afterEditor, { headerDefault: 'rIdHeader1' }); + + const emitSpy = vi.spyOn(beforeEditor, 'emit'); + const diff = beforeEditor.commands.compareDocuments(afterEditor); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(emitSpy).toHaveBeenCalledWith( + 'partChanged', + expect.objectContaining({ + source: 'diff-replay', + parts: expect.arrayContaining([ + expect.objectContaining({ partId: 'word/header1.xml', sectionId: 'rIdHeader1', operation: 'delete' }), + expect.objectContaining({ partId: 'word/header2.xml', sectionId: 'rIdHeader1', operation: 'create' }), + ]), + }), + ); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('compares and replays header removal', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index f8a8b9b23e..3e1f97c815 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -181,6 +181,11 @@ export function replayHeaderFooters({ changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'create', changedPaths: [] }); } for (const part of headerFootersDiff.modifiedParts) { + if (part.oldPartPath !== part.partPath) { + changedParts.push({ partId: part.oldPartPath, sectionId: part.refId, operation: 'delete', changedPaths: [] }); + changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'create', changedPaths: [] }); + continue; + } changedParts.push({ partId: part.partPath, sectionId: part.refId, operation: 'mutate', changedPaths: [] }); } for (const part of headerFootersDiff.removedParts) { From 5ea5b12befdf1593fc9cecd727b06a18dab16c78 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 15:24:39 -0300 Subject: [PATCH 26/41] 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. --- .../diffing/service/diff-service.test.ts | 25 +++++++++++++++++++ .../diffing/service/diff-service.ts | 12 +++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 736e0ee209..274a61175c 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -515,4 +515,29 @@ describe('diff-service tracked apply', () => { targetEditor.destroy?.(); } }); + + it('rejects v1 payloads that declare v2 header/footer coverage', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Updated document.'); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const invalidV1Diff = { + ...structuredClone(diff), + version: 'sd-diff-payload/v1' as const, + coverage: { + ...V1_COVERAGE, + headerFooters: true, + }, + }; + + expect(() => applyDiffPayload(baseEditor, invalidV1Diff, { changeMode: 'direct' })).toThrowError( + /coverage mismatch/i, + ); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); }); diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.ts index 34224ef6d6..f46b2efd48 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.ts @@ -339,6 +339,7 @@ export function applyDiffPayload( ): ApplyDiffResult { validateEngine(diffPayload.engine); validatePayloadVersion(diffPayload.version); + validateCoverageForPayloadVersion(diffPayload); validatePayloadFingerprints(diffPayload); // Verify base fingerprint matches current document @@ -816,6 +817,17 @@ function validateCoverageMatch(base: DiffCoverage, target: DiffCoverage): void { } } +function validateCoverageForPayloadVersion(diffPayload: DiffPayload): void { + const expectedCoverage = diffPayload.version === PAYLOAD_VERSION_V1 ? V1_COVERAGE : V2_COVERAGE; + if (!coverageEquals(diffPayload.coverage, expectedCoverage)) { + throw new DiffServiceError( + 'INVALID_INPUT', + `Coverage mismatch for payload version "${diffPayload.version}". ` + + `Expected ${JSON.stringify(expectedCoverage)}, got ${JSON.stringify(diffPayload.coverage)}.`, + ); + } +} + function getCoverageForSnapshotVersion(version: DiffSnapshot['version']): DiffCoverage { return version === 'sd-diff-snapshot/v1' ? V1_COVERAGE : V2_COVERAGE; } From ffa32b7a5acf548f654716ee76f4bdabdb65548a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 15:52:36 -0300 Subject: [PATCH 27/41] 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. --- .../extensions/diffing/headerFooters.test.ts | 38 +++++++++++++++++++ .../extensions/diffing/replay/replay-parts.ts | 9 +++++ .../src/extensions/diffing/replayDiffs.ts | 3 ++ 3 files changed, 50 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 003e3f3ac1..1f3fb8a788 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -5,6 +5,7 @@ import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { captureHeaderFooterState } from './algorithm/header-footer-diffing'; import { replayHeaderFooters } from './replay/replay-header-footers'; +import { replayPartsDiff } from './replay/replay-parts'; import { resolveSectionProjections } from '../../document-api-adapters/helpers/sections-resolver.js'; /** @@ -389,6 +390,43 @@ describe('Header/footer diffing', () => { } }); + it('publishes replayed media upserts through collaboration', () => { + const addImageToCollaboration = vi.fn(() => true); + + replayPartsDiff({ + partsDiff: { + upserts: { + 'word/media/header-logo.png': { + kind: 'binary', + content: 'data:image/png;base64,aGVhZGVy', + }, + }, + deletes: [], + }, + editor: { + commands: { + addImageToCollaboration, + }, + converter: { + convertedXml: {}, + }, + options: { + mediaFiles: {}, + }, + storage: { + image: { + media: {}, + }, + }, + }, + }); + + expect(addImageToCollaboration).toHaveBeenCalledWith({ + mediaPath: 'word/media/header-logo.png', + fileData: 'data:image/png;base64,aGVhZGVy', + }); + }); + it('exports a valid header part after replay adds a new header', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts index b5ac4e25fe..fed84d0296 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -2,6 +2,9 @@ import { ReplayResult } from './replay-types'; import type { PartsDiff } from '../algorithm/parts-diffing'; type ReplayPartsEditor = { + commands?: { + addImageToCollaboration?: (params: { mediaPath: string; fileData: string }) => boolean; + }; emit?: (event: string, payload?: unknown) => void; options?: { mediaFiles?: Record; @@ -69,6 +72,12 @@ export function replayPartsDiff({ const value = structuredClone(snapshot.content); optionMediaStore[partPath] = value; storageMediaStore[partPath] = structuredClone(value); + if (partPath.startsWith('word/media/') && typeof value === 'string') { + editor.commands?.addImageToCollaboration?.({ + mediaPath: partPath, + fileData: value, + }); + } changedParts.push({ partId: partPath, operation, changedPaths: [] }); } result.applied += 1; diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.ts b/packages/super-editor/src/extensions/diffing/replayDiffs.ts index aee1635626..0d546c83cb 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.ts +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.ts @@ -26,6 +26,9 @@ type ReplayDiffsParams = { schema: import('prosemirror-model').Schema; comments?: import('./algorithm/comment-diffing').CommentInput[]; editor?: { + commands?: { + addImageToCollaboration?: (params: { mediaPath: string; fileData: string }) => boolean; + }; state: { doc: import('prosemirror-model').Node }; emit?: (event: string, payload: unknown) => void; options?: { From 128af0d1adf07a86e8468de7a1717fa653938deb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 15:55:31 -0300 Subject: [PATCH 28/41] 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. --- .../diffing/algorithm/parts-diffing.ts | 35 +------------------ .../diffing/replay/replay-header-footers.ts | 1 - 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 0dff7c2962..2200d538e0 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -1,3 +1,4 @@ +import { resolveOpcTargetPath } from '../../../core/super-converter/helpers.js'; import type { HeaderFooterKind, HeaderFooterState, HeaderFootersDiff } from './header-footer-diffing'; export interface PartSnapshot { @@ -50,40 +51,6 @@ export type PartsStateEditor = { const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels'; -/** - * Resolves an OOXML relationship target using OPC (Open Packaging Conventions) - * package-path rules relative to the given part directory. - */ -function resolveOpcTargetPath(target: string, baseDir = 'word'): string | null { - if (!target || typeof target !== 'string') { - return null; - } - - const normalizedBaseDir = baseDir.replace(/^\/+|\/+$/g, ''); - const trimmedTarget = target.trim(); - if (!trimmedTarget) { - return null; - } - - if (trimmedTarget.startsWith('/')) { - return trimmedTarget.replace(/^\/+/, ''); - } - - const segments = normalizedBaseDir ? normalizedBaseDir.split('/').filter(Boolean) : []; - for (const segment of trimmedTarget.split('/')) { - if (!segment || segment === '.') { - continue; - } - if (segment === '..') { - segments.pop(); - continue; - } - segments.push(segment); - } - - return segments.join('/'); -} - /** * Captures the body and header/footer part closures needed for coarse * parts-aware replay. diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index 3e1f97c815..cc8520236e 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -193,7 +193,6 @@ export function replayHeaderFooters({ } editor.emit?.('partChanged', { parts: changedParts, source: 'diff-replay' }); - editor.emit?.('headerFooterUpdate', { type: 'replayCompleted' }); } return result; From 561342bdc6b20f4713e24cb032746fcf447fb598 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 16:18:33 -0300 Subject: [PATCH 29/41] fix(diffing-example): pass target editor to compareDocuments --- examples/features/diffing/src/App.vue | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/examples/features/diffing/src/App.vue b/examples/features/diffing/src/App.vue index 250c236138..9142140bc4 100644 --- a/examples/features/diffing/src/App.vue +++ b/examples/features/diffing/src/App.vue @@ -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 }); From f2303ae7dd82cb869633d4972a30cfaa9008b97e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 24 Mar 2026 16:23:49 -0300 Subject: [PATCH 30/41] fix(diffing): mark parts replay as document modified --- .../diffing/replay/replay-parts.test.ts | 82 +++++++++++++++++++ .../extensions/diffing/replay/replay-parts.ts | 2 + 2 files changed, 84 insertions(+) create mode 100644 packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts new file mode 100644 index 0000000000..5f10ba42c3 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { replayPartsDiff } from './replay-parts'; + +describe('replayPartsDiff', () => { + it('marks the converter dirty and emits when parts are changed', () => { + const converter = { + convertedXml: {}, + documentModified: false, + }; + const emit = vi.fn(); + + const result = replayPartsDiff({ + partsDiff: { + upserts: { + 'word/media/header-logo.png': { + kind: 'binary', + content: 'data:image/png;base64,aGVhZGVy', + }, + }, + deletes: [], + }, + editor: { + converter, + emit, + options: { + mediaFiles: {}, + }, + storage: { + image: { + media: {}, + }, + }, + }, + }); + + expect(result.applied).toBe(1); + expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); + expect(converter.documentModified).toBe(true); + expect(emit).toHaveBeenCalledWith( + 'partChanged', + expect.objectContaining({ + source: 'diff-replay', + parts: expect.arrayContaining([ + expect.objectContaining({ + partId: 'word/media/header-logo.png', + operation: 'create', + }), + ]), + }), + ); + }); + + it('does not mark the converter dirty when replay is skipped', () => { + const converter = { + documentModified: false, + }; + const emit = vi.fn(); + + const result = replayPartsDiff({ + partsDiff: { + upserts: { + 'word/header1.xml': { + kind: 'xml', + content: { elements: [] }, + }, + }, + deletes: [], + }, + editor: { + converter, + emit, + }, + }); + + expect(result.applied).toBe(0); + expect(result.skipped).toBe(1); + expect(result.warnings).toEqual(['Parts replay skipped: editor converter is unavailable.']); + expect(converter.documentModified).toBe(false); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts index fed84d0296..4d083b7ef8 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -16,6 +16,7 @@ type ReplayPartsEditor = { }; converter?: { convertedXml?: Record; + documentModified?: boolean; } | null; }; @@ -105,6 +106,7 @@ export function replayPartsDiff({ } if (changedParts.length > 0) { + editor.converter.documentModified = true; editor.emit?.('partChanged', { parts: changedParts, source: 'diff-replay' }); } From 8db10338fda535bf36fdc463623260f9a74d7eb5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 12:08:38 -0300 Subject: [PATCH 31/41] test: remove logs from test --- .../super-editor/src/extensions/diffing/replayDiffs.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js index efb3f9f7e3..8bf8876bf7 100644 --- a/packages/super-editor/src/extensions/diffing/replayDiffs.test.js +++ b/packages/super-editor/src/extensions/diffing/replayDiffs.test.js @@ -504,17 +504,15 @@ describe('investigate replay issues', () => { const success = beforeEditor.commands.replayDifferences(diff, { user: { user: { name: 'Test User', email: 'test@example.com' }, applyTrackedChanges: true }, }); - console.log('Replay success:', success); - + expect(success).toBe(true); expect(beforeEditor.state.doc.toJSON()).not.toEqual(originalDocJSON); expect(beforeEditor.state.doc.textContent).toBe(afterEditor.state.doc.textContent); - console.log(JSON.stringify(beforeEditor.state.doc.toJSON(), null, 2)); const replayDiffsResult = computeDiff( beforeEditor.state.doc, afterEditor.state.doc, beforeEditor.schema, ).docDiffs; - // expect(replayDiffsResult.every(isAcceptableRemainingDiff)).toBe(true); + expect(replayDiffsResult.every(isAcceptableRemainingDiff)).toBe(true); } finally { beforeEditor.destroy?.(); afterEditor.destroy?.(); From 52b7eba98cffe8eeef5e1e487d8ff7fb52b51233 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 12:47:04 -0300 Subject: [PATCH 32/41] fix: import error --- packages/super-editor/src/core/super-converter/helpers.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/super-editor/src/core/super-converter/helpers.d.ts b/packages/super-editor/src/core/super-converter/helpers.d.ts index 406d0d1346..659835140e 100644 --- a/packages/super-editor/src/core/super-converter/helpers.d.ts +++ b/packages/super-editor/src/core/super-converter/helpers.d.ts @@ -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 From 30b947fae12cc6f8152bda58519f52f3d5fe526a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 13:20:40 -0300 Subject: [PATCH 33/41] test: add missing test documents --- .../src/tests/data/diffing/diff_after19.docx | Bin 0 -> 24017 bytes .../src/tests/data/diffing/diff_before19.docx | Bin 0 -> 13327 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/super-editor/src/tests/data/diffing/diff_after19.docx create mode 100644 packages/super-editor/src/tests/data/diffing/diff_before19.docx diff --git a/packages/super-editor/src/tests/data/diffing/diff_after19.docx b/packages/super-editor/src/tests/data/diffing/diff_after19.docx new file mode 100644 index 0000000000000000000000000000000000000000..87fb2fdf7bbeb4996e26af2c327b4858f0d18cb6 GIT binary patch literal 24017 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 Date: Thu, 26 Mar 2026 14:05:56 -0300 Subject: [PATCH 34/41] fix: emit slot clears for removed header/footer sections --- .../algorithm/header-footer-diffing.ts | 30 ++++++++++- .../extensions/diffing/headerFooters.test.ts | 50 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts index ee4ae8b7a4..7cf7b40281 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/header-footer-diffing.ts @@ -143,9 +143,12 @@ export function diffHeaderFooters( } const previousSlots = new Map(previous.slots.map((slot) => [slot.sectionId, slot])); + const nextSlots = new Map(next.slots.map((slot) => [slot.sectionId, slot])); const slotChanges: HeaderFooterSlotState[] = []; - for (const nextSlot of next.slots) { - const previousSlot = previousSlots.get(nextSlot.sectionId); + const sectionIds = new Set([...previousSlots.keys(), ...nextSlots.keys()]); + for (const sectionId of sectionIds) { + const previousSlot = previousSlots.get(sectionId); + const nextSlot = nextSlots.get(sectionId) ?? createClearedSlotState(sectionId); if (!slotsEqual(previousSlot, nextSlot)) { slotChanges.push(structuredClone(nextSlot)); } @@ -303,6 +306,29 @@ function compareParts(a: HeaderFooterPartState, b: HeaderFooterPartState): numbe return a.refId.localeCompare(b.refId); } +/** + * Creates an explicit cleared slot payload for a section that no longer exists. + * + * @param sectionId Removed section id. + * @returns Slot state that clears title-page and variant refs on replay. + */ +function createClearedSlotState(sectionId: string): HeaderFooterSlotState { + return { + sectionId, + titlePg: false, + header: { + default: null, + first: null, + even: null, + }, + footer: { + default: null, + first: null, + even: null, + }, + }; +} + /** * Compares two slot states by value. * diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index 1f3fb8a788..fbcb91bef4 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -3,7 +3,7 @@ import { Editor } from '@core/Editor.js'; import { getStarterExtensions } from '@extensions/index.js'; import { getTestDataAsBuffer } from '@tests/export/export-helpers/export-helpers.js'; import { getTrackChanges } from '@extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; -import { captureHeaderFooterState } from './algorithm/header-footer-diffing'; +import { captureHeaderFooterState, diffHeaderFooters, type HeaderFooterState } from './algorithm/header-footer-diffing'; import { replayHeaderFooters } from './replay/replay-header-footers'; import { replayPartsDiff } from './replay/replay-parts'; import { resolveSectionProjections } from '../../document-api-adapters/helpers/sections-resolver.js'; @@ -275,6 +275,54 @@ function seedDefaultHeader(editor: Editor, text: string): void { } describe('Header/footer diffing', () => { + it('emits cleared slot changes when a section slot is removed', async () => { + const editor = await createEditor(); + + try { + const previous: HeaderFooterState = { + parts: [], + slots: [ + { + sectionId: 'section-a', + titlePg: false, + header: { default: 'rIdHeaderDefault', first: null, even: null }, + footer: { default: null, first: null, even: null }, + }, + { + sectionId: 'section-b', + titlePg: true, + header: { default: null, first: 'rIdHeaderFirst', even: null }, + footer: { default: null, first: 'rIdFooterFirst', even: null }, + }, + ], + }; + const next: HeaderFooterState = { + parts: [], + slots: [ + { + sectionId: 'section-a', + titlePg: false, + header: { default: 'rIdHeaderDefault', first: null, even: null }, + footer: { default: null, first: null, even: null }, + }, + ], + }; + + const diff = diffHeaderFooters(previous, next, editor.schema); + + expect(diff?.slotChanges).toEqual([ + { + sectionId: 'section-b', + titlePg: false, + header: { default: null, first: null, even: null }, + footer: { default: null, first: null, even: null }, + }, + ]); + } finally { + editor.destroy?.(); + } + }); + it('compares and replays a newly added header', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); From 93db6e3dca1679c0e528d4745e5c4a3d582b919a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:05:26 -0300 Subject: [PATCH 35/41] refactor: simplify replay parts media store initialization --- .../src/extensions/diffing/replay/replay-parts.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts index 4d083b7ef8..b906cf635d 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.ts @@ -50,10 +50,14 @@ export function replayPartsDiff({ return result; } - const optionMediaStore = - (editor.options ??= {}).mediaFiles ?? ((editor.options.mediaFiles = {}), editor.options.mediaFiles); - const storageImage = (editor.storage ??= {}).image ?? ((editor.storage.image = {}), editor.storage.image); - const storageMediaStore = storageImage.media ?? ((storageImage.media = {}), storageImage.media); + editor.options ??= {}; + editor.options.mediaFiles ??= {}; + const optionMediaStore = editor.options.mediaFiles; + + editor.storage ??= {}; + editor.storage.image ??= {}; + editor.storage.image.media ??= {}; + const storageMediaStore = editor.storage.image.media; const changedParts: Array<{ partId: string; operation: 'mutate' | 'create' | 'delete'; From 9157945f5b21e4bd6d73fe2946956ccc5f2db36a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:11:39 -0300 Subject: [PATCH 36/41] refactor: remove unused diffParts parameters --- .../src/extensions/diffing/algorithm/parts-diffing.test.ts | 2 +- .../src/extensions/diffing/algorithm/parts-diffing.ts | 4 +--- packages/super-editor/src/extensions/diffing/computeDiff.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts index 52b9636e90..9da1184684 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.test.ts @@ -129,7 +129,7 @@ describe('parts-diffing', () => { headerFooterClosures: {}, }; - const partsDiff = diffParts([], null, previousPartsState, nextPartsState); + const partsDiff = diffParts(previousPartsState, nextPartsState); expect(partsDiff).not.toBeNull(); expect(partsDiff?.upserts['word/media/image1.png']).toEqual({ diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 2200d538e0..8fb5ce2128 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -1,5 +1,5 @@ import { resolveOpcTargetPath } from '../../../core/super-converter/helpers.js'; -import type { HeaderFooterKind, HeaderFooterState, HeaderFootersDiff } from './header-footer-diffing'; +import type { HeaderFooterKind, HeaderFooterState } from './header-footer-diffing'; export interface PartSnapshot { kind: 'xml' | 'binary'; @@ -85,8 +85,6 @@ export function capturePartsState( * causes the captured body relationship closure to be compared and emitted. */ export function diffParts( - docDiffs: Array, - headerFootersDiff: HeaderFootersDiff | null | undefined, previousPartsState: PartsState | null | undefined, nextPartsState: PartsState | null | undefined, ): PartsDiff | null { diff --git a/packages/super-editor/src/extensions/diffing/computeDiff.ts b/packages/super-editor/src/extensions/diffing/computeDiff.ts index 6ada5a9ca0..366dce030d 100644 --- a/packages/super-editor/src/extensions/diffing/computeDiff.ts +++ b/packages/super-editor/src/extensions/diffing/computeDiff.ts @@ -65,8 +65,7 @@ export function computeDiff( ): DiffResult { const docDiffs = diffNodes(normalizeNodes(oldPmDoc), normalizeNodes(newPmDoc)); const headerFootersDiff = diffHeaderFooters(oldHeaderFooters, newHeaderFooters, schema); - const partsDiff = - oldPartsState && newPartsState ? diffParts(docDiffs, headerFootersDiff, oldPartsState, newPartsState) : null; + const partsDiff = oldPartsState && newPartsState ? diffParts(oldPartsState, newPartsState) : null; return { docDiffs, commentDiffs: diffComments(oldComments, newComments, schema), From 8fdeca64f746b26965994226ecd7733b0a55e91a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:23:39 -0300 Subject: [PATCH 37/41] refactor: share diffing rels path helper --- .../diffing/algorithm/parts-diffing.ts | 14 +------------- .../src/extensions/diffing/part-paths.ts | 16 ++++++++++++++++ .../diffing/replay/replay-header-footers.ts | 13 ++++++------- 3 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 packages/super-editor/src/extensions/diffing/part-paths.ts diff --git a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts index 8fb5ce2128..ebe1803653 100644 --- a/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts +++ b/packages/super-editor/src/extensions/diffing/algorithm/parts-diffing.ts @@ -1,4 +1,5 @@ import { resolveOpcTargetPath } from '../../../core/super-converter/helpers.js'; +import { toRelsPathForPart } from '../part-paths'; import type { HeaderFooterKind, HeaderFooterState } from './header-footer-diffing'; export interface PartSnapshot { @@ -292,19 +293,6 @@ function getPartBaseDir(partPath: string): string { return lastSlash >= 0 ? partPath.slice(0, lastSlash) : ''; } -function toRelsPathForPart(partPath: string): string | null { - if (partPath === DOCUMENT_RELS_PATH || partPath.endsWith('.rels')) { - return null; - } - const lastSlash = partPath.lastIndexOf('/'); - if (lastSlash < 0 || lastSlash === partPath.length - 1) { - return null; - } - const directory = partPath.slice(0, lastSlash); - const fileName = partPath.slice(lastSlash + 1); - return `${directory}/_rels/${fileName}.rels`; -} - function shouldCaptureBodyRelationship(type: string): boolean { return !BODY_RELATIONSHIP_EXCLUSIONS.has(type); } diff --git a/packages/super-editor/src/extensions/diffing/part-paths.ts b/packages/super-editor/src/extensions/diffing/part-paths.ts new file mode 100644 index 0000000000..5b9f68a768 --- /dev/null +++ b/packages/super-editor/src/extensions/diffing/part-paths.ts @@ -0,0 +1,16 @@ +const DOCUMENT_RELS_PATH = 'word/_rels/document.xml.rels'; + +export function toRelsPathForPart(partPath: string): string | null { + if (partPath === DOCUMENT_RELS_PATH || partPath.endsWith('.rels')) { + return null; + } + + const lastSlash = partPath.lastIndexOf('/'); + if (lastSlash < 0 || lastSlash === partPath.length - 1) { + return null; + } + + const directory = partPath.slice(0, lastSlash); + const fileName = partPath.slice(lastSlash + 1); + return `${directory}/_rels/${fileName}.rels`; +} diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts index cc8520236e..75514b64b0 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-header-footers.ts @@ -1,5 +1,6 @@ import { EditorState, type Transaction } from 'prosemirror-state'; import type { Schema } from 'prosemirror-model'; +import { toRelsPathForPart } from '../part-paths'; import { replayDocDiffs } from './replay-doc'; import { ReplayResult } from './replay-types'; import { @@ -462,7 +463,10 @@ function deleteHeaderFooterPart( removeRelationshipEntry(converter.convertedXml!, part.refId); delete converter.convertedXml![part.partPath]; - delete converter.convertedXml![toRelsPathForPart(part.partPath)]; + const relsPath = toRelsPathForPart(part.partPath); + if (relsPath) { + delete converter.convertedXml![relsPath]; + } } function updateHeaderFooterPartPath( @@ -501,7 +505,7 @@ function updateHeaderFooterPartPath( const oldRelsPath = toRelsPathForPart(part.oldPartPath); const nextRelsPath = toRelsPathForPart(part.partPath); - if (oldRelsPath !== nextRelsPath) { + if (oldRelsPath && nextRelsPath && oldRelsPath !== nextRelsPath) { const previousRels = converter.convertedXml![oldRelsPath]; if (previousRels) { converter.convertedXml![nextRelsPath] = previousRels; @@ -587,11 +591,6 @@ function ensureXmlPartExists(convertedXml: Record, part: Header }; } -function toRelsPathForPart(partPath: string): string { - const fileName = partPath.split('/').pop(); - return `word/_rels/${fileName}.rels`; -} - /** * Exports stored PM JSON content back into the OOXML XML part cache. * From b434afd660f45fcafe4a0d3b9ed381b1b24ee045 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:26:35 -0300 Subject: [PATCH 38/41] test: cover replay parts deletions --- .../diffing/replay/replay-parts.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts b/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts index 5f10ba42c3..1e749a546d 100644 --- a/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts +++ b/packages/super-editor/src/extensions/diffing/replay/replay-parts.test.ts @@ -51,6 +51,64 @@ describe('replayPartsDiff', () => { ); }); + it('removes deleted xml and media parts from converter caches and media stores', () => { + const converter = { + convertedXml: { + 'word/header1.xml': { elements: [{ name: 'w:hdr' }] }, + }, + documentModified: false, + }; + const emit = vi.fn(); + const mediaFiles = { + 'word/media/header-logo.png': 'data:image/png;base64,b2xk', + }; + const storageMedia = { + 'word/media/header-logo.png': 'data:image/png;base64,b2xk', + }; + + const result = replayPartsDiff({ + partsDiff: { + upserts: {}, + deletes: ['word/header1.xml', 'word/media/header-logo.png'], + }, + editor: { + converter, + emit, + options: { + mediaFiles, + }, + storage: { + image: { + media: storageMedia, + }, + }, + }, + }); + + expect(result.applied).toBe(2); + expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); + expect(converter.documentModified).toBe(true); + expect(converter.convertedXml['word/header1.xml']).toBeUndefined(); + expect(mediaFiles['word/media/header-logo.png']).toBeUndefined(); + expect(storageMedia['word/media/header-logo.png']).toBeUndefined(); + expect(emit).toHaveBeenCalledWith('partChanged', { + source: 'diff-replay', + parts: [ + { + partId: 'word/header1.xml', + operation: 'delete', + changedPaths: [], + }, + { + partId: 'word/media/header-logo.png', + operation: 'delete', + changedPaths: [], + }, + ], + }); + }); + it('does not mark the converter dirty when replay is skipped', () => { const converter = { documentModified: false, From 0a61b31367dd12f9cda436f65b2361a41e064746 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:28:33 -0300 Subject: [PATCH 39/41] test: add footer diff replay coverage --- .../extensions/diffing/headerFooters.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts index fbcb91bef4..fc229a7e06 100644 --- a/packages/super-editor/src/extensions/diffing/headerFooters.test.ts +++ b/packages/super-editor/src/extensions/diffing/headerFooters.test.ts @@ -274,6 +274,22 @@ function seedDefaultHeader(editor: Editor, text: string): void { setBodySection(editor, { headerDefault: 'rIdHeader1' }); } +/** + * Seeds one default footer for a single-section test document. + * + * @param editor Editor whose converter should be updated. + * @param text Footer text content. + */ +function seedDefaultFooter(editor: Editor, text: string): void { + seedPart(editor, { + kind: 'footer', + refId: 'rIdFooter1', + partPath: 'word/footer1.xml', + text, + }); + setBodySection(editor, { footerDefault: 'rIdFooter1' }); +} + describe('Header/footer diffing', () => { it('emits cleared slot changes when a section slot is removed', async () => { const editor = await createEditor(); @@ -344,6 +360,27 @@ describe('Header/footer diffing', () => { } }); + it('compares and replays a newly added footer', async () => { + const beforeEditor = await createEditor(); + const afterEditor = await createEditor(); + + try { + setBodySection(beforeEditor, {}); + seedDefaultFooter(afterEditor, 'New footer'); + + const diff = beforeEditor.commands.compareDocuments(afterEditor); + + expect(diff.headerFootersDiff?.addedParts).toHaveLength(1); + expect(diff.headerFootersDiff?.slotChanges).toHaveLength(1); + + expect(beforeEditor.commands.replayDifferences(diff, { applyTrackedChanges: false })).toBe(true); + expect(captureHeaderFooterState(beforeEditor)).toEqual(captureHeaderFooterState(afterEditor)); + } finally { + beforeEditor.destroy?.(); + afterEditor.destroy?.(); + } + }); + it('emits a header/footer refresh signal when replay adds a new header', async () => { const beforeEditor = await createEditor(); const afterEditor = await createEditor(); From 88210a8c20dfa613099a2819e1860f0fab281443 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 26 Mar 2026 16:34:20 -0300 Subject: [PATCH 40/41] test: cover snapshot compare for footer-only diffs --- .../diffing/service/diff-service.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index 274a61175c..770aff83e3 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -176,6 +176,16 @@ function seedDefaultHeader(editor: Editor, text: string): void { setBodySection(editor, { headerDefault: 'rIdHeader1' }); } +function seedDefaultFooter(editor: Editor, text: string): void { + seedPart(editor, { + kind: 'footer', + refId: 'rIdFooter1', + partPath: 'word/footer1.xml', + text, + }); + setBodySection(editor, { footerDefault: 'rIdFooter1' }); +} + async function openBlankDocxWithText(text: string): Promise { const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, 'base64'), { isHeadless: true, @@ -309,6 +319,30 @@ describe('diff-service tracked apply', () => { } }); + it('captures header/footer-only diffs from snapshots when body content is unchanged', async () => { + const baseEditor = await openBlankDocxWithText('Base document.'); + const targetEditor = await openBlankDocxWithText('Base document.'); + + try { + seedDefaultFooter(targetEditor, 'Footer only change'); + + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + + expect(diff.payload.docDiffs).toEqual([]); + expect(diff.payload.headerFootersDiff).not.toBeNull(); + expect((diff.payload.headerFootersDiff as { addedParts?: unknown[] }).addedParts).toHaveLength(1); + expect((diff.payload.headerFootersDiff as { slotChanges?: unknown[] }).slotChanges).toHaveLength(1); + expect(diff.payload.partsDiff).not.toBeNull(); + expect( + (diff.payload.partsDiff as { upserts?: Record }).upserts?.['word/_rels/document.xml.rels'], + ).toBeTruthy(); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + it('rejects snapshots whose comment identity was tampered after capture', async () => { const baseEditor = await openBlankDocxWithText('Base document.'); const targetEditor = await openBlankDocxWithText('Base document.'); From df47aefd6ff77cc91c775bb225ddba7f6ba216f9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 26 Mar 2026 17:38:54 -0300 Subject: [PATCH 41/41] 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 --- .../diff-adapter.test.ts | 133 +++++++++++++++ .../diff/header-footer-diff-roundtrip.ts | 151 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 packages/super-editor/src/document-api-adapters/diff-adapter.test.ts create mode 100644 tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts diff --git a/packages/super-editor/src/document-api-adapters/diff-adapter.test.ts b/packages/super-editor/src/document-api-adapters/diff-adapter.test.ts new file mode 100644 index 0000000000..7788c44def --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/diff-adapter.test.ts @@ -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 { + 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 { + 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; +} + +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; 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> = [ + { 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?.(); + } + }); +}); diff --git a/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts b/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts new file mode 100644 index 0000000000..ac4ec7b3c6 --- /dev/null +++ b/tests/doc-api-stories/tests/diff/header-footer-diff-roundtrip.ts @@ -0,0 +1,151 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const TEST_USER = { name: 'Review Bot', email: 'bot@example.com' }; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function unwrapNamed(payload: unknown, key?: string): T { + if (key && payload && typeof payload === 'object' && key in payload) { + return (payload as Record)[key] as T; + } + return unwrap(payload); +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +type SectionAddress = { kind: 'section'; sectionId: string }; + +function requireFirstSectionAddress(sectionsResult: any): SectionAddress { + const section = sectionsResult?.items?.[0]?.address; + if (section?.kind !== 'section' || typeof section.sectionId !== 'string') { + throw new Error('Unable to resolve the first section address from sections.list.'); + } + return section as SectionAddress; +} + +function createHeaderStoryLocator(section: SectionAddress) { + return { + kind: 'story' as const, + storyType: 'headerFooterSlot' as const, + section, + headerFooterKind: 'header' as const, + variant: 'default' as const, + onWrite: 'materializeIfInherited' as const, + }; +} + +describe('document-api story: header/footer diff roundtrip', () => { + const { client, outPath } = useStoryHarness('diff/header-footer-diff-roundtrip', { + preserveResults: true, + clientOptions: { + user: TEST_USER, + }, + }); + + it('diffs two docs with different headers and applies header changes to the base doc', async () => { + const baseSessionId = sid('hf-diff-base'); + const targetSessionId = sid('hf-diff-target'); + const reopenSessionId = sid('hf-diff-reopen'); + + const bodyText = 'Shared body text across both documents.'; + const headerText = 'Header added by diff target.'; + + // Open base doc (no header) + await client.doc.open({ + sessionId: baseSessionId, + contentOverride: bodyText, + overrideType: 'text', + }); + + // Open target doc with same body text, then add a header + await client.doc.open({ + sessionId: targetSessionId, + contentOverride: bodyText, + overrideType: 'text', + }); + + const sectionsResult = unwrapNamed(await client.doc.sections.list({ sessionId: targetSessionId }), 'result'); + const firstSection = requireFirstSectionAddress(sectionsResult); + const headerStory = createHeaderStoryLocator(firstSection); + + await client.doc.insert({ + sessionId: targetSessionId, + in: headerStory, + value: headerText, + }); + + // Capture snapshot from target (has header) + const snapshot = unwrapNamed(await client.doc.diff.capture({ sessionId: targetSessionId }), 'snapshot'); + expect(snapshot.version).toMatch(/^sd-diff-snapshot\/v[12]$/); + + await client.doc.close({ sessionId: targetSessionId, discard: true }); + + // Compare base against target snapshot + const diff = unwrapNamed( + await client.doc.diff.compare({ + sessionId: baseSessionId, + targetSnapshot: snapshot, + }), + 'diff', + ); + expect(diff.summary.hasChanges).toBe(true); + expect(diff.summary.headerFooters.hasChanges).toBe(true); + expect(diff.summary.changedComponents).toContain('headerFooters'); + + // Apply diff — header-only changes go through the adapter dispatch path + const applyResult = unwrapNamed( + await client.doc.diff.apply({ + sessionId: baseSessionId, + diff, + changeMode: 'direct', + }), + 'result', + ); + expect(applyResult.appliedOperations).toBeGreaterThan(0); + expect(applyResult.summary.headerFooters.hasChanges).toBe(true); + + // Save and verify header is in the exported DOCX + const outputPath = outPath('header-footer-diff-roundtrip.docx'); + await client.doc.save({ + sessionId: baseSessionId, + out: outputPath, + force: true, + }); + + const documentXml = await readDocxPart(outputPath, 'word/document.xml'); + expect(documentXml).toContain(bodyText); + expect(documentXml).toMatch(/(await client.doc.sections.list({ sessionId: reopenSessionId }), 'result'); + const reopenSection = requireFirstSectionAddress(reopenSections); + const reopenHeaderStory = createHeaderStoryLocator(reopenSection); + + const reopenHeaderText = await client.doc.getText({ + sessionId: reopenSessionId, + in: reopenHeaderStory, + }); + expect(reopenHeaderText).toContain(headerText); + }); +});