Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/super-editor/src/core/Editor.replace-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ function createTestEditor(options: Partial<Parameters<(typeof Editor)['prototype
describe('Editor.replaceFile', () => {
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(() => {
Expand Down Expand Up @@ -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<string, string> }) => 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();
}
});
});
17 changes: 17 additions & 0 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1535,10 +1536,26 @@ export class Editor extends EventEmitter<EditorEventMap> {
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);
Expand Down
183 changes: 160 additions & 23 deletions packages/super-editor/src/extensions/collaboration/collaboration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Comment thread
harbournick marked this conversation as resolved.
};

editor.on('transaction', bodySectPrTransactionHandler);
cleanupState.bodySectPrTransactionHandler = bodySectPrTransactionHandler;
}
};

export const Collaboration = Extension.create({
name: 'collaboration',

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading