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
91 changes: 91 additions & 0 deletions packages/super-editor/src/core/Editor.collaboration-seed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { Doc as YDoc } from 'yjs';
import { Editor } from './Editor.js';
import { getStarterExtensions } from '@extensions/index.js';
import { getTestDataAsFileBuffer } from '@tests/helpers/helpers.js';

type SyncHandler = (synced?: boolean) => void;

function createProviderStub() {
const listeners = {
sync: new Set<SyncHandler>(),
synced: new Set<SyncHandler>(),
};

return {
synced: false,
isSynced: false,
on(event: 'sync' | 'synced', handler: SyncHandler) {
listeners[event].add(handler);
},
off(event: 'sync' | 'synced', handler: SyncHandler) {
listeners[event].delete(handler);
},
emit(event: 'sync' | 'synced', value?: boolean) {
for (const handler of listeners[event]) {
handler(value);
}
},
};
}

function createTestEditor(options: Partial<ConstructorParameters<typeof Editor>[0]> = {}) {
return new Editor({
isHeadless: true,
deferDocumentLoad: true,
mode: 'docx',
extensions: getStarterExtensions(),
suppressDefaultDocxStyles: true,
...options,
});
}

describe('Editor collaboration seeding', () => {
let centeredBuffer: Buffer;

beforeAll(async () => {
centeredBuffer = await getTestDataAsFileBuffer('advanced-text.docx');
});

it('preserves the first paragraph attrs when seeding a collaborative room', async () => {
const provider = createProviderStub();
const ydoc = new YDoc();
const seededEditor = createTestEditor({
ydoc,
collaborationProvider: provider,
});
const directEditor = createTestEditor();

try {
await seededEditor.open(centeredBuffer, {
mode: 'docx',
isNewFile: true,
});
await directEditor.open(centeredBuffer, {
mode: 'docx',
});

provider.emit('synced', true);
await new Promise((resolve) => setTimeout(resolve, 0));

const seededFirstParagraph = seededEditor.state.doc.firstChild;
const directFirstParagraph = directEditor.state.doc.firstChild;

expect(seededFirstParagraph?.textContent).toBe(directFirstParagraph?.textContent);
expect(seededFirstParagraph?.attrs?.paraId).toBe(directFirstParagraph?.attrs?.paraId);
expect(seededFirstParagraph?.attrs?.paragraphProperties?.justification).toBe(
directFirstParagraph?.attrs?.paragraphProperties?.justification,
);
expect(seededFirstParagraph?.attrs?.attributes ?? null).toEqual(directFirstParagraph?.attrs?.attributes ?? null);
} finally {
if (seededEditor.lifecycleState === 'ready') {
seededEditor.close();
}
if (directEditor.lifecycleState === 'ready') {
directEditor.close();
}
seededEditor.destroy();
directEditor.destroy();
}
});
});
42 changes: 33 additions & 9 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,38 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.#dispatchTransaction(tr);
}

/**
* Sync root-level document attrs without mutating the first top-level node.
*/
#syncDocumentAttrs(nextAttrs: Record<string, unknown> = {}): void {
const currentAttrs = (this.state.doc?.attrs ?? {}) as Record<string, unknown>;
const docAttrSpecs = (this.schema?.topNodeType?.spec?.attrs ?? {}) as Record<string, { default?: unknown }>;
const attrKeys = new Set([...Object.keys(docAttrSpecs), ...Object.keys(currentAttrs), ...Object.keys(nextAttrs)]);

if (attrKeys.size === 0) return;

const valuesMatch = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b);

const tr = this.state.tr.setMeta('addToHistory', false);
let changed = false;

for (const key of attrKeys) {
const hasNextValue = Object.prototype.hasOwnProperty.call(nextAttrs, key);
const nextValue = hasNextValue ? nextAttrs[key] : docAttrSpecs[key]?.default;

if (valuesMatch(currentAttrs[key], nextValue)) {
continue;
}

tr.setDocAttribute(key, nextValue);
changed = true;
}

if (changed) {
this.#dispatchTransaction(tr);
}
}

/**
* Replace the current document with new data. Necessary for initializing a new collaboration file,
* since we need to insert the data only after the provider has synced.
Expand All @@ -1547,15 +1579,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
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);
}
this.#syncDocumentAttrs((doc.attrs ?? {}) as Record<string, unknown>);

setTimeout(() => {
this.#initComments();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ export const setSectionPageMarginsAtSelection =
}

// Write updated body sectPr onto the doc attrs so layout sees it immediately
const nextDocAttrs = { ...docAttrs, bodySectPr: sectPr };
tr.setNodeMarkup(0, undefined, nextDocAttrs);
tr.setDocAttribute('bodySectPr', sectPr);

tr.setMeta('forceUpdatePagination', true);
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,7 @@ function makeSectionsEditor(options: SectionEditorOptions = {}): Editor {
return tr;
}),
setNodeMarkup: vi.fn(() => tr),
setDocAttribute: vi.fn(() => tr),
setMeta: vi.fn(() => tr),
mapping: {
maps: [] as unknown[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ export function applySectPrToProjection(editor: Editor, projection: SectionProje
return;
}

const docAttrs = (editor.state.doc.attrs ?? {}) as Record<string, unknown>;
const tr = applyDirectMutationMeta(editor.state.tr);
tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr });
tr.setDocAttribute('bodySectPr', sectPr);
tr.setMeta('forceUpdatePagination', true);
editor.dispatch(tr);
syncConverterBodySection(editor, sectPr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,8 @@ function applySectPrToProjection(editor: Editor, projection: SectionProjection,
return;
}

const docAttrs = (editor.state.doc.attrs ?? {}) as Record<string, unknown>;
const tr = applyDirectMutationMeta(editor.state.tr);
tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr });
tr.setDocAttribute('bodySectPr', sectPr);
tr.setMeta('forceUpdatePagination', true);
editor.dispatch(tr);
syncConverterBodySection(editor, sectPr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,8 @@ const applyBodySectPrFromMetaMap = (editor, ydoc) => {

if (!editor?.state?.tr) return false;

const nextDocAttrs = {
...(editor.state.doc?.attrs ?? {}),
bodySectPr: nextBodySectPr,
};
const tr = editor.state.tr
.setNodeMarkup(0, undefined, nextDocAttrs)
.setDocAttribute('bodySectPr', nextBodySectPr)
.setMeta('addToHistory', false)
.setMeta(BODY_SECT_PR_SYNC_META_KEY, true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe('collaboration extension', () => {
ydoc._maps.metas.store.set('bodySectPr', bodySectPr);

const tr = {
setNodeMarkup: vi.fn(() => tr),
setDocAttribute: vi.fn(() => tr),
setMeta: vi.fn(() => tr),
};
const editor = {
Expand Down Expand Up @@ -242,10 +242,7 @@ describe('collaboration extension', () => {

ydoc._maps.metas._trigger(new Map([['bodySectPr', {}]]));

expect(tr.setNodeMarkup).toHaveBeenCalledWith(0, undefined, {
attributes: null,
bodySectPr,
});
expect(tr.setDocAttribute).toHaveBeenCalledWith('bodySectPr', bodySectPr);
expect(tr.setMeta).toHaveBeenCalledWith('addToHistory', false);
expect(tr.setMeta).toHaveBeenCalledWith('bodySectPrSync', true);
expect(editor.dispatch).toHaveBeenCalledWith(tr);
Expand Down
Loading