Skip to content

Commit 4372d0e

Browse files
authored
fix: header/footer undo history across story sessions (#2935)
* fix: header/footer undo history across story sessions
1 parent 6afb062 commit 4372d0e

12 files changed

Lines changed: 823 additions & 80 deletions

File tree

packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.test.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,23 @@ const { mockCreateHeaderFooterEditor, mockOnHeaderFooterDataUpdate, mockToFlowBl
6767
return editorStub;
6868
};
6969

70-
const mockCreateHeaderFooterEditor = vi.fn(() => {
71-
const editor = createSectionEditor();
72-
editors.push({ editor, emit: editor.emit });
73-
queueMicrotask(() => {
74-
editor.emit('create');
75-
});
76-
return editor;
77-
});
70+
const mockCreateHeaderFooterEditor = vi.fn(
71+
(input?: { editorContainer?: HTMLElement; editorHost?: HTMLElement }) => {
72+
const editor = createSectionEditor();
73+
if (input?.editorContainer instanceof HTMLElement) {
74+
if (input.editorHost instanceof HTMLElement) {
75+
input.editorHost.appendChild(input.editorContainer);
76+
} else {
77+
document.body.appendChild(input.editorContainer);
78+
}
79+
}
80+
editors.push({ editor, emit: editor.emit });
81+
queueMicrotask(() => {
82+
editor.emit('create');
83+
});
84+
return editor;
85+
},
86+
);
7887

7988
return {
8089
mockCreateHeaderFooterEditor,
@@ -192,6 +201,39 @@ describe('HeaderFooterEditorManager', () => {
192201
);
193202
});
194203

204+
it('ensureEditorSync creates a reusable editor instance immediately for presentation activation', () => {
205+
const editor = createMockEditor();
206+
const manager = new HeaderFooterEditorManager(editor);
207+
const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
208+
const host = document.createElement('div');
209+
210+
const first = manager.ensureEditorSync(descriptor, { editorHost: host });
211+
const second = manager.ensureEditorSync(descriptor, { editorHost: host });
212+
213+
expect(first).toBeDefined();
214+
expect(second).toBe(first);
215+
expect(mockCreateHeaderFooterEditor).toHaveBeenCalledTimes(1);
216+
expect(host.children).toHaveLength(1);
217+
});
218+
219+
it('ensureEditorSync reattaches the cached editor container to a new host', () => {
220+
const editor = createMockEditor();
221+
const manager = new HeaderFooterEditorManager(editor);
222+
const descriptor = { id: 'rId-header-default', kind: 'header' } as const;
223+
const firstHost = document.createElement('div');
224+
const secondHost = document.createElement('div');
225+
226+
const sectionEditor = manager.ensureEditorSync(descriptor, { editorHost: firstHost });
227+
expect(sectionEditor).toBeDefined();
228+
expect(firstHost.children).toHaveLength(1);
229+
230+
const sameEditor = manager.ensureEditorSync(descriptor, { editorHost: secondHost });
231+
232+
expect(sameEditor).toBe(sectionEditor);
233+
expect(firstHost.children).toHaveLength(0);
234+
expect(secondHost.children).toHaveLength(1);
235+
});
236+
195237
it('emits contentChanged and syncs converter/Yjs data when section editor updates', async () => {
196238
const editor = createMockEditor();
197239
const manager = new HeaderFooterEditorManager(editor);

packages/super-editor/src/editors/v1/core/header-footer/HeaderFooterRegistry.ts

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -330,39 +330,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
330330
console.error('[HeaderFooterEditorManager] Editor initialization failed:', error);
331331
this.emit('error', { descriptor, error });
332332
});
333-
334-
// Move editor container to the new editorHost if provided
335-
// This is necessary because cached editors may have been appended elsewhere
336-
if (existing.container && options?.editorHost) {
337-
// Only move if not already in the target host
338-
if (existing.container.parentElement !== options.editorHost) {
339-
options.editorHost.appendChild(existing.container);
340-
}
341-
}
342-
343-
// Update editor options if provided
344-
if (existing.editor && options) {
345-
const updateOptions: Record<string, unknown> = {};
346-
if (options.currentPageNumber !== undefined) {
347-
updateOptions.currentPageNumber = options.currentPageNumber;
348-
}
349-
if (options.totalPageCount !== undefined) {
350-
updateOptions.totalPageCount = options.totalPageCount;
351-
}
352-
if (options.availableWidth !== undefined) {
353-
updateOptions.availableWidth = options.availableWidth;
354-
}
355-
if (options.availableHeight !== undefined) {
356-
updateOptions.availableHeight = options.availableHeight;
357-
}
358-
if (Object.keys(updateOptions).length > 0) {
359-
existing.editor.setOptions(updateOptions);
360-
// Refresh page number display after option changes.
361-
// NodeViews read editor.options but PM doesn't re-render them
362-
// when only options change (no document transaction).
363-
this.#refreshPageNumberDisplay(existing.editor);
364-
}
365-
}
333+
this.#mountAndUpdateEntry(existing, options);
366334

367335
return existing.editor;
368336
}
@@ -380,7 +348,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
380348
// Start creation and track the promise
381349
const creationPromise = (async () => {
382350
try {
383-
const entry = await this.#createEditor(descriptor, options);
351+
const entry = this.#createEditorEntry(descriptor, options);
384352
if (!entry) return null;
385353

386354
this.#editorEntries.set(descriptor.id, entry);
@@ -406,6 +374,44 @@ export class HeaderFooterEditorManager extends EventEmitter {
406374
return creationPromise;
407375
}
408376

377+
/**
378+
* Synchronously returns the cached editor for a descriptor, creating it on demand.
379+
*
380+
* Presentation-mode story activation needs a stable editor instance and DOM
381+
* target immediately so input can be forwarded into the hidden host without
382+
* waiting for the async `create` event. The normal lifecycle hooks still run
383+
* through the returned entry's `ready` promise.
384+
*/
385+
ensureEditorSync(
386+
descriptor: HeaderFooterDescriptor,
387+
options?: {
388+
editorHost?: HTMLElement;
389+
availableWidth?: number;
390+
availableHeight?: number;
391+
currentPageNumber?: number;
392+
totalPageCount?: number;
393+
},
394+
): Editor | null {
395+
if (!descriptor?.id) return null;
396+
397+
const existing = this.#editorEntries.get(descriptor.id);
398+
if (existing) {
399+
this.#cacheHits += 1;
400+
this.#updateAccessOrder(descriptor.id);
401+
this.#mountAndUpdateEntry(existing, options);
402+
return existing.editor;
403+
}
404+
405+
const entry = this.#createEditorEntry(descriptor, options);
406+
if (!entry) return null;
407+
408+
this.#cacheMisses += 1;
409+
this.#editorEntries.set(descriptor.id, entry);
410+
this.#updateAccessOrder(descriptor.id);
411+
this.#enforceCacheSizeLimit();
412+
return entry.editor;
413+
}
414+
409415
/**
410416
* Updates page number DOM elements to reflect current editor options.
411417
* Called after setOptions to sync NodeViews that read editor.options.
@@ -671,7 +677,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
671677
this.#editorEntries.clear();
672678
}
673679

674-
async #createEditor(
680+
#createEditorEntry(
675681
descriptor: HeaderFooterDescriptor,
676682
options?: {
677683
editorHost?: HTMLElement;
@@ -680,7 +686,7 @@ export class HeaderFooterEditorManager extends EventEmitter {
680686
currentPageNumber?: number;
681687
totalPageCount?: number;
682688
},
683-
): Promise<HeaderFooterEditorEntry | null> {
689+
): HeaderFooterEditorEntry | null {
684690
const json = this.getDocumentJson(descriptor);
685691
if (!json) return null;
686692

@@ -799,6 +805,45 @@ export class HeaderFooterEditorManager extends EventEmitter {
799805
};
800806
}
801807

808+
#mountAndUpdateEntry(
809+
entry: HeaderFooterEditorEntry,
810+
options?: {
811+
editorHost?: HTMLElement;
812+
availableWidth?: number;
813+
availableHeight?: number;
814+
currentPageNumber?: number;
815+
totalPageCount?: number;
816+
},
817+
): void {
818+
if (entry.container && options?.editorHost && entry.container.parentElement !== options.editorHost) {
819+
options.editorHost.appendChild(entry.container);
820+
}
821+
822+
if (!options) {
823+
return;
824+
}
825+
826+
const updateOptions: Record<string, unknown> = {};
827+
if (options.currentPageNumber !== undefined) {
828+
updateOptions.currentPageNumber = options.currentPageNumber;
829+
}
830+
if (options.totalPageCount !== undefined) {
831+
updateOptions.totalPageCount = options.totalPageCount;
832+
}
833+
if (options.availableWidth !== undefined) {
834+
updateOptions.availableWidth = options.availableWidth;
835+
}
836+
if (options.availableHeight !== undefined) {
837+
updateOptions.availableHeight = options.availableHeight;
838+
}
839+
if (Object.keys(updateOptions).length > 0) {
840+
entry.editor.setOptions(updateOptions);
841+
// NodeViews that render PAGE / NUMPAGES read editor.options, so refresh
842+
// them when the presentation context changes without a document step.
843+
this.#refreshPageNumberDisplay(entry.editor);
844+
}
845+
}
846+
802847
#createEditorContainer(): HTMLElement {
803848
const doc =
804849
(this.#editor.options?.element?.ownerDocument as Document | undefined) ?? globalThis.document ?? undefined;

0 commit comments

Comments
 (0)