Skip to content

Commit 9e9c076

Browse files
committed
chore: review fixes
1 parent 672209c commit 9e9c076

6 files changed

Lines changed: 189 additions & 9 deletions

File tree

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import { prepareCommentsForExport, prepareCommentsForImport } from '@extensions/
3636
import DocxZipper from '@core/DocxZipper.js';
3737
import { generateCollaborationData } from '@extensions/collaboration/collaboration.js';
3838
import { useHighContrastMode } from '../composables/use-high-contrast-mode.js';
39-
import { scheduleReconcile } from '@extensions/collaboration/part-sync/part-reconcile-scheduler.js';
39+
import {
40+
scheduleReconcile,
41+
reconcileImmediately,
42+
} from '@extensions/collaboration/part-sync/part-reconcile-scheduler.js';
4043
import { setImageNodeSelection } from './helpers/setImageNodeSelection.js';
4144
import { canRenderFont } from './helpers/canRenderFont.js';
4245
import {
@@ -1492,16 +1495,21 @@ export class Editor extends EventEmitter<EditorEventMap> {
14921495
* If we are replacing data and have a valid provider, listen for synced event
14931496
* so that we can initialize the data
14941497
*/
1495-
initializeCollaborationData(): void {
1498+
initializeCollaborationData(afterInsert?: () => void): void {
14961499
if (!this.options.isNewFile || !this.options.collaborationProvider) return;
14971500
const provider = this.options.collaborationProvider;
14981501

1502+
const doInsert = () => {
1503+
this.#insertNewFileData();
1504+
afterInsert?.();
1505+
};
1506+
14991507
const postSyncInit = () => {
15001508
provider.off?.('synced', postSyncInit);
1501-
this.#insertNewFileData();
1509+
doInsert();
15021510
};
15031511

1504-
if (provider.synced) this.#insertNewFileData();
1512+
if (provider.synced) doInsert();
15051513
// If we are not sync'd yet, wait for the event then insert the data
15061514
else provider.on?.('synced', postSyncInit);
15071515
}
@@ -3191,8 +3199,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
31913199
this.initDefaultStyles();
31923200

31933201
if (this.options.ydoc && this.options.collaborationProvider) {
3194-
scheduleReconcile(this, 'replaceFile');
3195-
this.initializeCollaborationData();
3202+
this.initializeCollaborationData(() => reconcileImmediately(this));
31963203
} else {
31973204
this.#insertNewFileData();
31983205
}

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2641,7 +2641,7 @@ export class PresentationEditor extends EventEmitter {
26412641
const PART_INVALIDATION: Record<string, { clearCaches: boolean }> = {
26422642
styles: { clearCaches: true },
26432643
numbering: { clearCaches: true },
2644-
themeColors: { clearCaches: true },
2644+
theme: { clearCaches: true },
26452645
};
26462646

26472647
const handlePartChanged = (payload: { partId: string; changedPaths: string[]; source: string }) => {

packages/super-editor/src/extensions/collaboration/part-sync/part-reconcile-scheduler.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,22 @@ export function scheduleReconcile(editor, _reason) {
152152
}
153153
}
154154

155+
/**
156+
* Run a reconcile immediately, bypassing the debounce/maxWait timers.
157+
*
158+
* Use this after operations like `replaceFile` where structured channels must
159+
* be consistent before any other client can observe the new state.
160+
*
161+
* Automatically marks the editor dirty so the reconcile is not skipped.
162+
*
163+
* @param {object} editor
164+
* @returns {Promise<void>}
165+
*/
166+
export async function reconcileImmediately(editor) {
167+
markDirty(editor);
168+
await executeReconcile(editor);
169+
}
170+
155171
/**
156172
* Clear all pending timers and per-editor state. Called on editor destroy.
157173
*

packages/super-editor/src/extensions/collaboration/part-sync/part-reconcile-scheduler.test.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { scheduleReconcile, destroyReconcileState, markDirty } from './part-reconcile-scheduler.js';
2+
import {
3+
scheduleReconcile,
4+
destroyReconcileState,
5+
markDirty,
6+
reconcileImmediately,
7+
} from './part-reconcile-scheduler.js';
38
import { publishPartSections } from './part-sync-engine.js';
49
import { getOoxmlPartSpecs, invalidateDiscoveredSpecs } from './part-spec-registry.js';
510

@@ -485,4 +490,61 @@ describe('part-reconcile-scheduler', () => {
485490
expect(() => markDirty(null)).not.toThrow();
486491
});
487492
});
493+
494+
describe('reconcileImmediately', () => {
495+
it('runs exportDocx and publishPartSections synchronously (no timer)', async () => {
496+
const editor = createMockEditor();
497+
498+
await reconcileImmediately(editor);
499+
500+
expect(editor.exportDocx).toHaveBeenCalledOnce();
501+
expect(editor.exportDocx).toHaveBeenCalledWith({ getUpdatedDocs: true });
502+
expect(publishPartSections).toHaveBeenCalledTimes(2);
503+
});
504+
505+
it('does not require a prior markDirty call', async () => {
506+
const editor = createMockEditor();
507+
508+
// reconcileImmediately marks dirty internally
509+
await reconcileImmediately(editor);
510+
511+
expect(editor.exportDocx).toHaveBeenCalledOnce();
512+
});
513+
514+
it('cancels any pending debounced reconcile timers', async () => {
515+
const editor = createMockEditor();
516+
517+
markDirty(editor);
518+
scheduleReconcile(editor, 'pending');
519+
520+
// Immediate reconcile runs and clears timers
521+
await reconcileImmediately(editor);
522+
expect(editor.exportDocx).toHaveBeenCalledOnce();
523+
524+
editor.exportDocx.mockClear();
525+
publishPartSections.mockClear();
526+
527+
// Advancing past debounce should NOT fire again (no new dirty)
528+
await advanceAndFlush(DEBOUNCE_MS + MAX_WAIT_MS);
529+
expect(editor.exportDocx).not.toHaveBeenCalled();
530+
});
531+
532+
it('skips if editor is destroyed', async () => {
533+
const editor = createMockEditor();
534+
editor.isDestroyed = true;
535+
536+
await reconcileImmediately(editor);
537+
538+
expect(editor.exportDocx).not.toHaveBeenCalled();
539+
});
540+
541+
it('skips if ydoc is missing', async () => {
542+
const editor = createMockEditor();
543+
editor.options.ydoc = null;
544+
545+
await reconcileImmediately(editor);
546+
547+
expect(editor.exportDocx).not.toHaveBeenCalled();
548+
});
549+
});
488550
});

packages/super-editor/src/extensions/collaboration/part-sync/part-spec-registry.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { translator as docDefaultsTranslator } from '../../../core/super-convert
3434
import { translator as latentStylesTranslator } from '../../../core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.js';
3535
import { translator as styleTranslator } from '../../../core/super-converter/v3/handlers/w/style/style-translator.js';
3636
import { incrementRevision } from '../../../document-api-adapters/plan-engine/revision-tracker.js';
37+
import { translator as numberingTranslator } from '../../../core/super-converter/v3/handlers/w/numbering/numbering-translator.js';
3738

3839
// ---------------------------------------------------------------------------
3940
// Factory: xml-js parts with a single 'root' section
@@ -388,7 +389,26 @@ export const CONTENT_TYPES_SPEC = {
388389
// OOXML part specs (shared channel: ooxmlPartModels)
389390
// ---------------------------------------------------------------------------
390391

391-
export const NUMBERING_SPEC = createXmlPartSpec('numbering', 'word/numbering.xml');
392+
export const NUMBERING_SPEC = {
393+
...createXmlPartSpec('numbering', 'word/numbering.xml'),
394+
395+
afterApply: (editor) => {
396+
const converter = editor.converter;
397+
if (!converter) return;
398+
399+
const part = converter.parts?.['word/numbering.xml'] ?? converter.convertedXml?.['word/numbering.xml'];
400+
const rootElement = part?.elements?.[0];
401+
402+
// Re-translate raw XML into the model the layout engine reads.
403+
// If the part was deleted (no root element), clear to empty model so
404+
// collaborators don't keep stale list definitions.
405+
converter.translatedNumbering = rootElement
406+
? numberingTranslator.encode({ nodes: [rootElement] })
407+
: { abstracts: {}, definitions: {} };
408+
409+
incrementRevision(editor);
410+
},
411+
};
392412
export const SETTINGS_SPEC = createXmlPartSpec('settings', 'word/settings.xml');
393413
export const DOCUMENT_RELS_SPEC = createXmlPartSpec('documentRels', 'word/_rels/document.xml.rels');
394414
export const FOOTNOTES_SPEC = createXmlPartSpec('footnotes', 'word/footnotes.xml');

packages/super-editor/src/extensions/collaboration/part-sync/part-spec-registry.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ vi.mock('../../../document-api-adapters/plan-engine/revision-tracker.js', () =>
2323
incrementRevision: vi.fn(),
2424
}));
2525

26+
const mockNumberingTranslator = vi.hoisted(() => ({
27+
encode: vi.fn(() => ({ abstracts: { 0: {} }, definitions: { 1: {} } })),
28+
}));
29+
vi.mock('../../../core/super-converter/v3/handlers/w/numbering/numbering-translator.js', () => ({
30+
translator: mockNumberingTranslator,
31+
}));
32+
2633
import {
2734
STYLES_SPEC,
2835
NUMBERING_SPEC,
@@ -38,6 +45,7 @@ import {
3845
CUSTOM_PROPS_SPEC,
3946
CORE_PROPS_SPEC,
4047
FONT_TABLE_RELS_SPEC,
48+
THEME_SPEC,
4149
HEADER_FOOTER_RELS_SPEC,
4250
HEADER_FOOTER_CONTENT_SPEC,
4351
CONTENT_TYPES_SPEC,
@@ -119,6 +127,10 @@ describe('Registry functions', () => {
119127
expect(result).toEqual({ spec: NUMBERING_SPEC, section: 'root' });
120128
});
121129

130+
it('THEME_SPEC.id is "theme" (must match PresentationEditor PART_INVALIDATION key)', () => {
131+
expect(THEME_SPEC.id).toBe('theme');
132+
});
133+
122134
it('resolveOoxmlPartKey("unknown/something") returns null', () => {
123135
expect(resolveOoxmlPartKey('unknown/something')).toBeNull();
124136
});
@@ -221,6 +233,69 @@ describe('createXmlPartSpec (NUMBERING_SPEC)', () => {
221233
NUMBERING_SPEC.applySection(converter, 'root', value);
222234
expect(converter.convertedXml['word/numbering.xml'].elements[0]).toBe(value);
223235
});
236+
237+
describe('afterApply', () => {
238+
it('re-translates numbering XML into converter.translatedNumbering', () => {
239+
const rootElement = { name: 'w:numbering', elements: [{ name: 'w:abstractNum' }] };
240+
const converter = {
241+
convertedXml: { 'word/numbering.xml': { elements: [rootElement] } },
242+
translatedNumbering: null,
243+
};
244+
const editor = { converter };
245+
246+
NUMBERING_SPEC.afterApply(editor, ['root']);
247+
248+
expect(mockNumberingTranslator.encode).toHaveBeenCalledWith({ nodes: [rootElement] });
249+
expect(converter.translatedNumbering).toEqual({ abstracts: { 0: {} }, definitions: { 1: {} } });
250+
});
251+
252+
it('calls incrementRevision', () => {
253+
const rootElement = { name: 'w:numbering' };
254+
const converter = {
255+
convertedXml: { 'word/numbering.xml': { elements: [rootElement] } },
256+
translatedNumbering: null,
257+
};
258+
const editor = { converter };
259+
260+
NUMBERING_SPEC.afterApply(editor, ['root']);
261+
262+
expect(incrementRevision).toHaveBeenCalledWith(editor);
263+
});
264+
265+
it('skips translation when converter is missing', () => {
266+
const editor = { converter: null };
267+
expect(() => NUMBERING_SPEC.afterApply(editor, ['root'])).not.toThrow();
268+
expect(mockNumberingTranslator.encode).not.toHaveBeenCalled();
269+
});
270+
271+
it('clears translatedNumbering to empty model when numbering XML is deleted', () => {
272+
const converter = {
273+
convertedXml: { 'word/numbering.xml': { elements: [] } },
274+
translatedNumbering: { abstracts: { 0: { levels: {} } }, definitions: { 1: {} } },
275+
};
276+
const editor = { converter };
277+
278+
NUMBERING_SPEC.afterApply(editor, ['root']);
279+
280+
expect(mockNumberingTranslator.encode).not.toHaveBeenCalled();
281+
expect(converter.translatedNumbering).toEqual({ abstracts: {}, definitions: {} });
282+
expect(incrementRevision).toHaveBeenCalledWith(editor);
283+
});
284+
285+
it('reads from converter.parts when convertedXml is missing the part', () => {
286+
const rootElement = { name: 'w:numbering' };
287+
const converter = {
288+
parts: { 'word/numbering.xml': { elements: [rootElement] } },
289+
convertedXml: {},
290+
translatedNumbering: null,
291+
};
292+
const editor = { converter };
293+
294+
NUMBERING_SPEC.afterApply(editor, ['root']);
295+
296+
expect(mockNumberingTranslator.encode).toHaveBeenCalledWith({ nodes: [rootElement] });
297+
});
298+
});
224299
});
225300

226301
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)