diff --git a/packages/super-editor/src/core/Editor.replace-file.test.ts b/packages/super-editor/src/core/Editor.replace-file.test.ts index 0bffd813cc..a1b0ed1348 100644 --- a/packages/super-editor/src/core/Editor.replace-file.test.ts +++ b/packages/super-editor/src/core/Editor.replace-file.test.ts @@ -45,10 +45,12 @@ function createTestEditor(options: Partial { let blankDocData: { docx: unknown; mediaFiles: unknown; fonts: unknown }; let replacementBuffer: Buffer; + let multiSectionReplacementBuffer: Buffer; beforeAll(async () => { blankDocData = await loadTestDataForEditorTests('blank-doc.docx'); replacementBuffer = await getTestDataAsFileBuffer('Hello docx world.docx'); + multiSectionReplacementBuffer = await getTestDataAsFileBuffer('multi_section_doc.docx'); }); afterEach(() => { @@ -183,4 +185,42 @@ describe('Editor.replaceFile', () => { expectedEditor.destroy(); } }); + + it('seeds collaborative bodySectPr metadata when replacing a file with a final section', async () => { + const provider = createProviderStub(); + const ydoc = new YDoc(); + + const editor = createTestEditor({ + ydoc, + collaborationProvider: provider, + }); + + try { + await editor.open(undefined, { + mode: 'docx', + content: blankDocData.docx as any, + mediaFiles: blankDocData.mediaFiles as any, + fonts: blankDocData.fonts as any, + }); + + const replacePromise = editor.replaceFile(multiSectionReplacementBuffer); + await Promise.resolve(); + + provider.emit('synced', true); + await replacePromise; + + const bodySectPr = editor.options.ydoc?.getMap('meta').get('bodySectPr') as any; + expect(bodySectPr).toBeTruthy(); + const pageSize = bodySectPr.elements.find( + (element: { name?: string; attributes?: Record }) => element.name === 'w:pgSz', + ); + expect(pageSize).toBeTruthy(); + expect(Number(pageSize.attributes['w:w'])).toBeGreaterThan(Number(pageSize.attributes['w:h'])); + } finally { + if (editor.lifecycleState === 'ready') { + editor.close(); + } + editor.destroy(); + } + }); }); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 2c62e846f0..14f115f461 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2,6 +2,7 @@ import type { EditorState, Transaction, Plugin } from 'prosemirror-state'; import { Transform } from 'prosemirror-transform'; import type { EditorView as PmEditorView } from 'prosemirror-view'; import type { Node as PmNode, Schema } from 'prosemirror-model'; +import type { Doc as YDoc } from 'yjs'; import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js'; import type { EditorHelpers, ExtensionStorage, ProseMirrorJSON, PageStyles, Toolbar } from './types/EditorTypes.js'; import type { ChainableCommandObject, CanObject, EditorCommands } from './types/ChainedCommands.js'; @@ -1535,10 +1536,26 @@ export class Editor extends EventEmitter { if (!this.options.isNewFile) return; this.options.isNewFile = false; const doc = this.#generatePmData(); + const nextBodySectPr = JSON.parse(JSON.stringify(doc.attrs?.bodySectPr ?? null)); // hiding this transaction from history so it doesn't appear in undo stack const tr = this.state.tr.replaceWith(0, this.state.doc.content.size, doc).setMeta('addToHistory', false); this.#dispatchTransaction(tr); + const ydoc = this.options.ydoc as YDoc | null; + if (ydoc) { + ydoc.getMap('meta').set('bodySectPr', nextBodySectPr); + } + + if (Object.keys(doc.attrs).length > 0) { + const attrsTr = this.state.tr + .setNodeMarkup(0, undefined, { + ...(this.state.doc.attrs ?? {}), + ...(doc.attrs ?? {}), + }) + .setMeta('addToHistory', false); + this.#dispatchTransaction(attrsTr); + } + setTimeout(() => { this.#initComments(); }, 50); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index 2911f90373..e8165c49c2 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -12,6 +12,8 @@ import { seedPartsFromEditor } from './part-sync/seed-parts.js'; export const CollaborationPluginKey = new PluginKey('collaboration'); const headlessBindingStateByEditor = new WeakMap(); const headlessCleanupRegisteredEditors = new WeakSet(); +const META_BODY_SECT_PR_KEY = 'bodySectPr'; +const BODY_SECT_PR_SYNC_META_KEY = 'bodySectPrSync'; // Store Y.js observer references outside of reactive `this.options` to avoid // Vue's deep traverse hitting circular references inside Y.js Map internals. @@ -27,6 +29,115 @@ const registerHeadlessBindingCleanup = (editor, cleanup) => { }); }; +const cloneJsonValue = (value) => { + if (value == null) return null; + return JSON.parse(JSON.stringify(value)); +}; + +const serializeComparableValue = (value) => JSON.stringify(value ?? null); + +const getEditorBodySectPr = (editor) => editor?.state?.doc?.attrs?.bodySectPr ?? null; + +const setEditorConverterBodySectPr = (editor, bodySectPr) => { + if (!editor?.converter) return; + editor.converter.bodySectPr = cloneJsonValue(bodySectPr); +}; + +const syncBodySectPrToMetaMap = (ydoc, editor) => { + const metaMap = ydoc.getMap('meta'); + const nextBodySectPr = cloneJsonValue(getEditorBodySectPr(editor)); + const currentMetaBodySectPr = cloneJsonValue(metaMap.get(META_BODY_SECT_PR_KEY) ?? null); + + setEditorConverterBodySectPr(editor, nextBodySectPr); + + if (serializeComparableValue(nextBodySectPr) === serializeComparableValue(currentMetaBodySectPr)) { + return false; + } + + metaMap.set(META_BODY_SECT_PR_KEY, nextBodySectPr); + return true; +}; + +const applyBodySectPrFromMetaMap = (editor, ydoc) => { + const nextBodySectPr = cloneJsonValue(ydoc.getMap('meta').get(META_BODY_SECT_PR_KEY) ?? null); + const currentBodySectPr = cloneJsonValue(getEditorBodySectPr(editor)); + + setEditorConverterBodySectPr(editor, nextBodySectPr); + + if (serializeComparableValue(nextBodySectPr) === serializeComparableValue(currentBodySectPr)) { + return false; + } + + if (!editor?.state?.tr) return false; + + const nextDocAttrs = { + ...(editor.state.doc?.attrs ?? {}), + bodySectPr: nextBodySectPr, + }; + const tr = editor.state.tr + .setNodeMarkup(0, undefined, nextDocAttrs) + .setMeta('addToHistory', false) + .setMeta(BODY_SECT_PR_SYNC_META_KEY, true); + + if (typeof editor.dispatch === 'function') { + editor.dispatch(tr); + return true; + } + + if (typeof editor.view?.dispatch === 'function') { + editor.view.dispatch(tr); + return true; + } + + return false; +}; + +const registerBodySectPrSync = (editor, ydoc, provider, cleanupState) => { + const metaMap = ydoc.getMap('meta'); + const metaMapObserver = (event) => { + if (!event?.changes?.keys?.has?.(META_BODY_SECT_PR_KEY)) return; + applyBodySectPrFromMetaMap(editor, ydoc); + }; + metaMap.observe(metaMapObserver); + cleanupState.metaMap = metaMap; + cleanupState.metaMapObserver = metaMapObserver; + + const applyInitialBodySectPr = () => { + if (editor.isDestroyed) return; + applyBodySectPrFromMetaMap(editor, ydoc); + }; + + if (!provider) { + applyInitialBodySectPr(); + } else { + cleanupState.bodySectPrPendingCleanup = onCollaborationProviderSynced(provider, applyInitialBodySectPr); + } + + if (typeof editor.on === 'function' && !editor.options?.isHeadless) { + const bodySectPrTransactionHandler = ({ transaction }) => { + if (!transaction || transaction.getMeta?.(BODY_SECT_PR_SYNC_META_KEY)) return; + + const isYjsOrigin = Boolean(transaction.getMeta?.(ySyncPluginKey)?.isChangeOrigin); + if (isYjsOrigin) { + applyBodySectPrFromMetaMap(editor, ydoc); + return; + } + + const previousBodySectPr = cloneJsonValue(transaction.before?.attrs?.bodySectPr ?? null); + const nextBodySectPr = cloneJsonValue(getEditorBodySectPr(editor)); + + if (serializeComparableValue(previousBodySectPr) === serializeComparableValue(nextBodySectPr)) { + return; + } + + syncBodySectPrToMetaMap(ydoc, editor); + }; + + editor.on('transaction', bodySectPrTransactionHandler); + cleanupState.bodySectPrTransactionHandler = bodySectPrTransactionHandler; + } +}; + export const Collaboration = Extension.create({ name: 'collaboration', @@ -66,11 +177,17 @@ export const Collaboration = Extension.create({ const cleanupState = { mediaMap, mediaMapObserver, + metaMap: null, + metaMapObserver: null, partSyncHandle: null, partSyncPendingCleanup: null, + bodySectPrPendingCleanup: null, + bodySectPrTransactionHandler: null, }; collaborationCleanupByEditor.set(this.editor, cleanupState); + registerBodySectPrSync(this.editor, this.options.ydoc, this.editor.options.collaborationProvider, cleanupState); + // Bootstrap part-sync (publisher + consumer) — always active. // Requires a full editor with event emitter — skip for minimal test mocks. // Deferred until provider is synced so Yjs state is available for @@ -117,10 +234,15 @@ export const Collaboration = Extension.create({ // Clean up Y.js media map observer cleanup.mediaMap.unobserve(cleanup.mediaMapObserver); + cleanup.metaMap?.unobserve?.(cleanup.metaMapObserver); // Clean up part-sync publisher/consumer (or pending sync listener) cleanup.partSyncHandle?.destroy(); cleanup.partSyncPendingCleanup?.(); + cleanup.bodySectPrPendingCleanup?.(); + if (cleanup.bodySectPrTransactionHandler && typeof this.editor.off === 'function') { + this.editor.off('transaction', cleanup.bodySectPrTransactionHandler); + } collaborationCleanupByEditor.delete(this.editor); }, @@ -165,7 +287,10 @@ export const initializeMetaMap = (ydoc, editor) => { mediaMap.set(key, value); }); - // 3. Bootstrap metadata + // 3. Sync root-level section defaults that Yjs fragments cannot represent. + syncBodySectPrToMetaMap(ydoc, editor); + + // 4. Bootstrap metadata const metaMap = ydoc.getMap('meta'); metaMap.set('fonts', editor.options.fonts); metaMap.set('bootstrap', { @@ -284,37 +409,49 @@ const initHeadlessBinding = (editor) => { // Skip if this transaction originated from Y.js (avoid infinite loop) const meta = transaction.getMeta(ySyncPluginKey); - if (meta?.isChangeOrigin) return; + if (meta?.isChangeOrigin) { + applyBodySectPrFromMetaMap(editor, editor.options.ydoc); + return; + } + if (transaction.getMeta?.(BODY_SECT_PR_SYNC_META_KEY)) return; + + const previousBodySectPr = cloneJsonValue(transaction.before?.attrs?.bodySectPr ?? null); + const nextBodySectPr = cloneJsonValue(getEditorBodySectPr(editor)); + const bodySectPrChanged = serializeComparableValue(previousBodySectPr) !== serializeComparableValue(nextBodySectPr); + // Sync document content to Y.js via binding (when available) const binding = ensureInitializedBinding(); - if (!binding) return; - // Sync ProseMirror changes to Y.js - if (typeof binding._prosemirrorChanged !== 'function') return; - const addToHistory = transaction.getMeta('addToHistory') !== false; + if (binding && typeof binding._prosemirrorChanged === 'function') { + const addToHistory = transaction.getMeta('addToHistory') !== false; - // Match y-prosemirror view.update behavior for non-history changes. - if (!addToHistory) { - const undoPluginState = yUndoPluginKey.getState(editor.state); - undoPluginState?.undoManager?.stopCapturing?.(); - } + // Match y-prosemirror view.update behavior for non-history changes. + if (!addToHistory) { + const undoPluginState = yUndoPluginKey.getState(editor.state); + undoPluginState?.undoManager?.stopCapturing?.(); + } - const syncToYjs = () => { - const ydoc = editor.options.ydoc; - if (!ydoc) return; + const syncToYjs = () => { + const ydoc = editor.options.ydoc; + if (!ydoc) return; - ydoc.transact((tr) => { - tr?.meta?.set?.('addToHistory', addToHistory); - binding._prosemirrorChanged(editor.state.doc); - }, ySyncPluginKey); - }; + ydoc.transact((tr) => { + tr?.meta?.set?.('addToHistory', addToHistory); + binding._prosemirrorChanged(editor.state.doc); + }, ySyncPluginKey); + }; - if (typeof binding.mux === 'function') { - binding.mux(syncToYjs); - return; + if (typeof binding.mux === 'function') { + binding.mux(syncToYjs); + } else { + syncToYjs(); + } } - syncToYjs(); + // Sync bodySectPr metadata separately (not part of Y.js fragment) + if (bodySectPrChanged) { + syncBodySectPrToMetaMap(editor.options.ydoc, editor); + } }; editor.on('transaction', transactionHandler); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index c9f86e0a61..81179257e6 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -37,17 +37,20 @@ const { Collaboration, CollaborationPluginKey, createSyncPlugin, initializeMetaM const createYMap = (initial = {}) => { const store = new Map(Object.entries(initial)); - let observer; + const observers = new Set(); return { set: vi.fn((key, value) => { store.set(key, value); }), get: vi.fn((key) => store.get(key)), observe: vi.fn((fn) => { - observer = fn; + observers.add(fn); + }), + unobserve: vi.fn((fn) => { + observers.delete(fn); }), _trigger(keys) { - observer?.({ changes: { keys } }); + observers.forEach((observer) => observer({ changes: { keys } })); }, store, }; @@ -138,10 +141,10 @@ describe('collaboration extension', () => { const context = { editor, options: {} }; Collaboration.config.addPmPlugins.call(context); - const syncHandler = provider.on.mock.calls.find(([event]) => event === 'sync')?.[1]; - expect(syncHandler).toBeTypeOf('function'); + const syncHandlers = provider.on.mock.calls.filter(([event]) => event === 'sync').map(([, handler]) => handler); + expect(syncHandlers.length).toBeGreaterThan(0); - syncHandler(true); + syncHandlers.forEach((handler) => handler(true)); expect(editor.emit).toHaveBeenCalledWith('collaborationReady', { editor, ydoc }); }); @@ -171,6 +174,17 @@ describe('collaboration extension', () => { it('initializes meta map with fonts, bootstrap metadata, and media', () => { const ydoc = createYDocStub(); const editor = { + state: { + doc: { + attrs: { + bodySectPr: { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:pgSz', attributes: { 'w:orient': 'landscape' } }], + }, + }, + }, + }, options: { content: { 'word/document.xml': '' }, fonts: { 'font1.ttf': new Uint8Array([1]) }, @@ -183,10 +197,109 @@ describe('collaboration extension', () => { const metaStore = ydoc._maps.metas.store; // initializeMetaMap no longer writes 'docx' — parts are seeded via seedPartsFromEditor expect(metaStore.get('fonts')).toEqual(editor.options.fonts); + expect(metaStore.get('bodySectPr')).toEqual(editor.state.doc.attrs.bodySectPr); expect(metaStore.get('bootstrap')).toEqual(expect.objectContaining({ version: 1, source: 'browser' })); expect(ydoc._maps.media.set).toHaveBeenCalledWith('word/media/img.png', new Uint8Array([5])); }); + it('applies bodySectPr from the meta map when the meta entry changes', () => { + const ydoc = createYDocStub(); + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const bodySectPr = { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:pgSz', attributes: { 'w:orient': 'landscape' } }], + }; + ydoc._maps.metas.store.set('bodySectPr', bodySectPr); + + const tr = { + setNodeMarkup: vi.fn(() => tr), + setMeta: vi.fn(() => tr), + }; + const editor = { + options: { + isHeadless: false, + ydoc, + collaborationProvider: provider, + }, + state: { + doc: { + attrs: { + attributes: null, + bodySectPr: null, + }, + }, + tr, + }, + dispatch: vi.fn(), + storage: { image: { media: {} } }, + emit: vi.fn(), + view: { state: { doc: {} }, dispatch: vi.fn() }, + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + + ydoc._maps.metas._trigger(new Map([['bodySectPr', {}]])); + + expect(tr.setNodeMarkup).toHaveBeenCalledWith(0, undefined, { + attributes: null, + bodySectPr, + }); + expect(tr.setMeta).toHaveBeenCalledWith('addToHistory', false); + expect(tr.setMeta).toHaveBeenCalledWith('bodySectPrSync', true); + expect(editor.dispatch).toHaveBeenCalledWith(tr); + }); + + it('publishes bodySectPr changes from local transactions into the meta map', () => { + const ydoc = createYDocStub(); + const provider = { synced: false, on: vi.fn(), off: vi.fn() }; + const bodySectPr = { + type: 'element', + name: 'w:sectPr', + elements: [{ type: 'element', name: 'w:pgSz', attributes: { 'w:orient': 'landscape' } }], + }; + const editor = { + options: { + isHeadless: false, + ydoc, + collaborationProvider: provider, + }, + state: { + doc: { + attrs: { + attributes: null, + bodySectPr, + }, + }, + }, + storage: { image: { media: {} } }, + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + view: { state: { doc: {} }, dispatch: vi.fn() }, + }; + + const context = { editor, options: {} }; + Collaboration.config.addPmPlugins.call(context); + + const bodySectPrTransactionHandler = editor.on.mock.calls.find(([event]) => event === 'transaction')?.[1]; + expect(bodySectPrTransactionHandler).toBeTypeOf('function'); + + bodySectPrTransactionHandler({ + transaction: { + before: { + attrs: { + bodySectPr: null, + }, + }, + getMeta: vi.fn(() => null), + }, + }); + + expect(ydoc._maps.metas.set).toHaveBeenCalledWith('bodySectPr', bodySectPr); + }); + it('generates collaboration data and encodes ydoc update', async () => { const ydoc = createYDocStub(); const doc = { type: 'doc' };