Skip to content

Commit 018469a

Browse files
authored
fix(super-editor): preserve root doc attrs during collaboration seeding (#2359)
1 parent 863254a commit 018469a

8 files changed

Lines changed: 131 additions & 25 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { beforeAll, describe, expect, it } from 'vitest';
2+
import { Doc as YDoc } from 'yjs';
3+
import { Editor } from './Editor.js';
4+
import { getStarterExtensions } from '@extensions/index.js';
5+
import { getTestDataAsFileBuffer } from '@tests/helpers/helpers.js';
6+
7+
type SyncHandler = (synced?: boolean) => void;
8+
9+
function createProviderStub() {
10+
const listeners = {
11+
sync: new Set<SyncHandler>(),
12+
synced: new Set<SyncHandler>(),
13+
};
14+
15+
return {
16+
synced: false,
17+
isSynced: false,
18+
on(event: 'sync' | 'synced', handler: SyncHandler) {
19+
listeners[event].add(handler);
20+
},
21+
off(event: 'sync' | 'synced', handler: SyncHandler) {
22+
listeners[event].delete(handler);
23+
},
24+
emit(event: 'sync' | 'synced', value?: boolean) {
25+
for (const handler of listeners[event]) {
26+
handler(value);
27+
}
28+
},
29+
};
30+
}
31+
32+
function createTestEditor(options: Partial<ConstructorParameters<typeof Editor>[0]> = {}) {
33+
return new Editor({
34+
isHeadless: true,
35+
deferDocumentLoad: true,
36+
mode: 'docx',
37+
extensions: getStarterExtensions(),
38+
suppressDefaultDocxStyles: true,
39+
...options,
40+
});
41+
}
42+
43+
describe('Editor collaboration seeding', () => {
44+
let centeredBuffer: Buffer;
45+
46+
beforeAll(async () => {
47+
centeredBuffer = await getTestDataAsFileBuffer('advanced-text.docx');
48+
});
49+
50+
it('preserves the first paragraph attrs when seeding a collaborative room', async () => {
51+
const provider = createProviderStub();
52+
const ydoc = new YDoc();
53+
const seededEditor = createTestEditor({
54+
ydoc,
55+
collaborationProvider: provider,
56+
});
57+
const directEditor = createTestEditor();
58+
59+
try {
60+
await seededEditor.open(centeredBuffer, {
61+
mode: 'docx',
62+
isNewFile: true,
63+
});
64+
await directEditor.open(centeredBuffer, {
65+
mode: 'docx',
66+
});
67+
68+
provider.emit('synced', true);
69+
await new Promise((resolve) => setTimeout(resolve, 0));
70+
71+
const seededFirstParagraph = seededEditor.state.doc.firstChild;
72+
const directFirstParagraph = directEditor.state.doc.firstChild;
73+
74+
expect(seededFirstParagraph?.textContent).toBe(directFirstParagraph?.textContent);
75+
expect(seededFirstParagraph?.attrs?.paraId).toBe(directFirstParagraph?.attrs?.paraId);
76+
expect(seededFirstParagraph?.attrs?.paragraphProperties?.justification).toBe(
77+
directFirstParagraph?.attrs?.paragraphProperties?.justification,
78+
);
79+
expect(seededFirstParagraph?.attrs?.attributes ?? null).toEqual(directFirstParagraph?.attrs?.attributes ?? null);
80+
} finally {
81+
if (seededEditor.lifecycleState === 'ready') {
82+
seededEditor.close();
83+
}
84+
if (directEditor.lifecycleState === 'ready') {
85+
directEditor.close();
86+
}
87+
seededEditor.destroy();
88+
directEditor.destroy();
89+
}
90+
});
91+
});

packages/super-editor/src/core/Editor.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,38 @@ export class Editor extends EventEmitter<EditorEventMap> {
15291529
this.#dispatchTransaction(tr);
15301530
}
15311531

1532+
/**
1533+
* Sync root-level document attrs without mutating the first top-level node.
1534+
*/
1535+
#syncDocumentAttrs(nextAttrs: Record<string, unknown> = {}): void {
1536+
const currentAttrs = (this.state.doc?.attrs ?? {}) as Record<string, unknown>;
1537+
const docAttrSpecs = (this.schema?.topNodeType?.spec?.attrs ?? {}) as Record<string, { default?: unknown }>;
1538+
const attrKeys = new Set([...Object.keys(docAttrSpecs), ...Object.keys(currentAttrs), ...Object.keys(nextAttrs)]);
1539+
1540+
if (attrKeys.size === 0) return;
1541+
1542+
const valuesMatch = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b);
1543+
1544+
const tr = this.state.tr.setMeta('addToHistory', false);
1545+
let changed = false;
1546+
1547+
for (const key of attrKeys) {
1548+
const hasNextValue = Object.prototype.hasOwnProperty.call(nextAttrs, key);
1549+
const nextValue = hasNextValue ? nextAttrs[key] : docAttrSpecs[key]?.default;
1550+
1551+
if (valuesMatch(currentAttrs[key], nextValue)) {
1552+
continue;
1553+
}
1554+
1555+
tr.setDocAttribute(key, nextValue);
1556+
changed = true;
1557+
}
1558+
1559+
if (changed) {
1560+
this.#dispatchTransaction(tr);
1561+
}
1562+
}
1563+
15321564
/**
15331565
* Replace the current document with new data. Necessary for initializing a new collaboration file,
15341566
* since we need to insert the data only after the provider has synced.
@@ -1547,15 +1579,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
15471579
ydoc.getMap('meta').set('bodySectPr', nextBodySectPr);
15481580
}
15491581

1550-
if (Object.keys(doc.attrs).length > 0) {
1551-
const attrsTr = this.state.tr
1552-
.setNodeMarkup(0, undefined, {
1553-
...(this.state.doc.attrs ?? {}),
1554-
...(doc.attrs ?? {}),
1555-
})
1556-
.setMeta('addToHistory', false);
1557-
this.#dispatchTransaction(attrsTr);
1558-
}
1582+
this.#syncDocumentAttrs((doc.attrs ?? {}) as Record<string, unknown>);
15591583

15601584
setTimeout(() => {
15611585
this.#initComments();

packages/super-editor/src/core/commands/setSectionPageMarginsAtSelection.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ export const setSectionPageMarginsAtSelection =
137137
}
138138

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

143142
tr.setMeta('forceUpdatePagination', true);
144143
return true;

packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,7 @@ function makeSectionsEditor(options: SectionEditorOptions = {}): Editor {
13561356
return tr;
13571357
}),
13581358
setNodeMarkup: vi.fn(() => tr),
1359+
setDocAttribute: vi.fn(() => tr),
13591360
setMeta: vi.fn(() => tr),
13601361
mapping: {
13611362
maps: [] as unknown[],

packages/super-editor/src/document-api-adapters/helpers/section-mutation-wrapper.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,8 @@ export function applySectPrToProjection(editor: Editor, projection: SectionProje
106106
return;
107107
}
108108

109-
const docAttrs = (editor.state.doc.attrs ?? {}) as Record<string, unknown>;
110109
const tr = applyDirectMutationMeta(editor.state.tr);
111-
tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr });
110+
tr.setDocAttribute('bodySectPr', sectPr);
112111
tr.setMeta('forceUpdatePagination', true);
113112
editor.dispatch(tr);
114113
syncConverterBodySection(editor, sectPr);

packages/super-editor/src/document-api-adapters/sections-adapter.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,8 @@ function applySectPrToProjection(editor: Editor, projection: SectionProjection,
245245
return;
246246
}
247247

248-
const docAttrs = (editor.state.doc.attrs ?? {}) as Record<string, unknown>;
249248
const tr = applyDirectMutationMeta(editor.state.tr);
250-
tr.setNodeMarkup(0, undefined, { ...docAttrs, bodySectPr: sectPr });
249+
tr.setDocAttribute('bodySectPr', sectPr);
251250
tr.setMeta('forceUpdatePagination', true);
252251
editor.dispatch(tr);
253252
syncConverterBodySection(editor, sectPr);

packages/super-editor/src/extensions/collaboration/collaboration.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,8 @@ const applyBodySectPrFromMetaMap = (editor, ydoc) => {
7070

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

73-
const nextDocAttrs = {
74-
...(editor.state.doc?.attrs ?? {}),
75-
bodySectPr: nextBodySectPr,
76-
};
7773
const tr = editor.state.tr
78-
.setNodeMarkup(0, undefined, nextDocAttrs)
74+
.setDocAttribute('bodySectPr', nextBodySectPr)
7975
.setMeta('addToHistory', false)
8076
.setMeta(BODY_SECT_PR_SYNC_META_KEY, true);
8177

packages/super-editor/src/extensions/collaboration/collaboration.test.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ describe('collaboration extension', () => {
213213
ydoc._maps.metas.store.set('bodySectPr', bodySectPr);
214214

215215
const tr = {
216-
setNodeMarkup: vi.fn(() => tr),
216+
setDocAttribute: vi.fn(() => tr),
217217
setMeta: vi.fn(() => tr),
218218
};
219219
const editor = {
@@ -242,10 +242,7 @@ describe('collaboration extension', () => {
242242

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

245-
expect(tr.setNodeMarkup).toHaveBeenCalledWith(0, undefined, {
246-
attributes: null,
247-
bodySectPr,
248-
});
245+
expect(tr.setDocAttribute).toHaveBeenCalledWith('bodySectPr', bodySectPr);
249246
expect(tr.setMeta).toHaveBeenCalledWith('addToHistory', false);
250247
expect(tr.setMeta).toHaveBeenCalledWith('bodySectPrSync', true);
251248
expect(editor.dispatch).toHaveBeenCalledWith(tr);

0 commit comments

Comments
 (0)