Skip to content

Commit 78d0056

Browse files
fix(superdoc): expose header/footer edits in update callbacks (#2368)
* fix(superdoc): expose header/footer edits in update callbacks * fix(super-editor): teardown header/footer editor bridge on session reset * refactor(super-editor): simplify header/footer bridge listener cleanup * refactor(superdoc): share editor event payload builder fields * test(superdoc): cover header/footer editor event payload paths
1 parent 89c982f commit 78d0056

8 files changed

Lines changed: 458 additions & 11 deletions

File tree

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type {
2323
SuperDocReadyEvent,
2424
SuperDocEditorCreateEvent,
2525
SuperDocEditorUpdateEvent,
26+
SuperDocTransactionEvent,
2627
SuperDocContentErrorEvent,
2728
SuperDocExceptionEvent,
2829
} from './types';

packages/react/src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ export interface SuperDocEditorCreateEvent {
5151
}
5252

5353
/** Event passed to onEditorUpdate callback */
54-
export interface SuperDocEditorUpdateEvent {
55-
editor: Editor;
56-
}
54+
export type SuperDocEditorUpdateEvent = Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0];
55+
56+
/** Event passed to onTransaction callback */
57+
export type SuperDocTransactionEvent = Parameters<NonNullable<SuperDocConstructorConfig['onTransaction']>>[0];
5758

5859
/** Event passed to onContentError callback */
5960
export interface SuperDocContentErrorEvent {

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3172,6 +3172,26 @@ export class PresentationEditor extends EventEmitter {
31723172
onUpdateAwarenessSession: () => {
31733173
this.#updateAwarenessSession();
31743174
},
3175+
onSurfaceUpdate: ({ sourceEditor, surface, headerId, sectionType }) => {
3176+
this.emit('headerFooterUpdate', {
3177+
editor: this.#editor,
3178+
sourceEditor,
3179+
surface,
3180+
headerId,
3181+
sectionType,
3182+
});
3183+
},
3184+
onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => {
3185+
this.emit('headerFooterTransaction', {
3186+
editor: this.#editor,
3187+
sourceEditor,
3188+
surface,
3189+
headerId,
3190+
sectionType,
3191+
transaction,
3192+
duration,
3193+
});
3194+
},
31753195
});
31763196

31773197
// Initialize the registry

packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@ export type SessionManagerCallbacks = {
158158
onAnnounce?: (message: string) => void;
159159
/** Called to update awareness session */
160160
onUpdateAwarenessSession?: (session: HeaderFooterSession) => void;
161+
/** Called when the active header/footer editor emits an update */
162+
onSurfaceUpdate?: (data: {
163+
sourceEditor: Editor;
164+
surface: 'header' | 'footer';
165+
headerId?: string | null;
166+
sectionType?: string | null;
167+
}) => void;
168+
/** Called when the active header/footer editor emits a transaction */
169+
onSurfaceTransaction?: (data: {
170+
sourceEditor: Editor;
171+
surface: 'header' | 'footer';
172+
headerId?: string | null;
173+
sectionType?: string | null;
174+
transaction: unknown;
175+
duration?: number;
176+
}) => void;
161177
};
162178

163179
// =============================================================================
@@ -198,6 +214,7 @@ export class HeaderFooterSessionManager {
198214
// Session state
199215
#session: HeaderFooterSession = { mode: 'body' };
200216
#activeEditor: Editor | null = null;
217+
#activeEditorEventCleanup: (() => void) | null = null;
201218

202219
// Hover UI elements (passed in, not owned)
203220
#hoverOverlay: HTMLElement | null = null;
@@ -415,6 +432,7 @@ export class HeaderFooterSessionManager {
415432
resetSession: () => {
416433
this.#managerCleanups = [];
417434
this.#session = { mode: 'body' };
435+
this.#teardownActiveEditorEventBridge();
418436
this.#activeEditor = null;
419437
this.#deps?.notifyInputBridgeTargetChanged();
420438
},
@@ -640,6 +658,7 @@ export class HeaderFooterSessionManager {
640658
this.#activeEditor.setEditable(false);
641659
this.#activeEditor.setOptions({ documentMode: 'viewing' });
642660
}
661+
this.#teardownActiveEditorEventBridge();
643662

644663
this.#overlayManager?.hideEditingOverlay();
645664
this.#overlayManager?.showSelectionOverlay();
@@ -687,6 +706,7 @@ export class HeaderFooterSessionManager {
687706
this.#activeEditor.setEditable(false);
688707
this.#activeEditor.setOptions({ documentMode: 'viewing' });
689708
}
709+
this.#teardownActiveEditorEventBridge();
690710
this.#overlayManager.hideEditingOverlay();
691711
this.#activeEditor = null;
692712
this.#session = { mode: 'body' };
@@ -833,6 +853,7 @@ export class HeaderFooterSessionManager {
833853
this.#overlayManager.hideSelectionOverlay();
834854

835855
this.#activeEditor = editor;
856+
this.#setupActiveEditorEventBridge(editor);
836857
this.#session = {
837858
mode: region.kind,
838859
kind: region.kind,
@@ -861,6 +882,7 @@ export class HeaderFooterSessionManager {
861882
this.#overlayManager?.hideEditingOverlay();
862883
this.#overlayManager?.showSelectionOverlay();
863884
this.clearHover();
885+
this.#teardownActiveEditorEventBridge();
864886
this.#activeEditor = null;
865887
this.#session = { mode: 'body' };
866888
} catch (cleanupError) {
@@ -928,6 +950,50 @@ export class HeaderFooterSessionManager {
928950
this.#callbacks.onAnnounce?.(message);
929951
}
930952

953+
#setupActiveEditorEventBridge(editor: Editor): void {
954+
this.#teardownActiveEditorEventBridge();
955+
956+
const emitSurfaceUpdate = () => {
957+
if (this.#session.mode !== 'header' && this.#session.mode !== 'footer') return;
958+
this.#callbacks.onSurfaceUpdate?.({
959+
sourceEditor: editor,
960+
surface: this.#session.mode,
961+
headerId: this.#session.headerId ?? null,
962+
sectionType: this.#session.sectionType ?? null,
963+
});
964+
};
965+
966+
const emitSurfaceTransaction = ({ transaction, duration }: { transaction: unknown; duration?: number }) => {
967+
if (this.#session.mode !== 'header' && this.#session.mode !== 'footer') return;
968+
this.#callbacks.onSurfaceTransaction?.({
969+
sourceEditor: editor,
970+
surface: this.#session.mode,
971+
headerId: this.#session.headerId ?? null,
972+
sectionType: this.#session.sectionType ?? null,
973+
transaction,
974+
duration,
975+
});
976+
};
977+
978+
editor.on('update', emitSurfaceUpdate);
979+
editor.on('transaction', emitSurfaceTransaction);
980+
981+
this.#activeEditorEventCleanup = () => {
982+
editor.off?.('update', emitSurfaceUpdate);
983+
editor.off?.('transaction', emitSurfaceTransaction);
984+
};
985+
}
986+
987+
#teardownActiveEditorEventBridge(): void {
988+
try {
989+
this.#activeEditorEventCleanup?.();
990+
} catch (error) {
991+
console.warn('[HeaderFooterSessionManager] Failed to clean up active editor bridge:', error);
992+
} finally {
993+
this.#activeEditorEventCleanup = null;
994+
}
995+
}
996+
931997
#updateModeBanner(): void {
932998
if (!this.#modeBanner) return;
933999
if (this.#session.mode === 'body') {
@@ -1537,6 +1603,8 @@ export class HeaderFooterSessionManager {
15371603
* Clean up all resources.
15381604
*/
15391605
destroy(): void {
1606+
this.#teardownActiveEditorEventBridge();
1607+
15401608
// Run cleanup functions
15411609
this.#managerCleanups.forEach((fn) => {
15421610
try {

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

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,6 +2088,200 @@ describe('PresentationEditor', () => {
20882088
boundingSpy.mockRestore();
20892089
});
20902090

2091+
it('re-emits live header/footer child editor updates and transactions', async () => {
2092+
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());
2093+
2094+
editor = new PresentationEditor({
2095+
element: container,
2096+
documentId: 'test-doc',
2097+
});
2098+
2099+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
2100+
await new Promise((resolve) => setTimeout(resolve, 100));
2101+
2102+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
2103+
const mockPage = document.createElement('div');
2104+
mockPage.setAttribute('data-page-index', '0');
2105+
pagesHost.appendChild(mockPage);
2106+
2107+
const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
2108+
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
2109+
left: 0,
2110+
top: 0,
2111+
width: 800,
2112+
height: 1000,
2113+
right: 800,
2114+
bottom: 1000,
2115+
x: 0,
2116+
y: 0,
2117+
toJSON: () => ({}),
2118+
} as DOMRect);
2119+
2120+
const updateSpy = vi.fn();
2121+
const transactionSpy = vi.fn();
2122+
editor.on('headerFooterUpdate', updateSpy);
2123+
editor.on('headerFooterTransaction', transactionSpy);
2124+
2125+
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));
2126+
2127+
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
2128+
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
2129+
2130+
const sourceEditor = editor.getActiveEditor();
2131+
expect(sourceEditor).toBeDefined();
2132+
2133+
const transaction = { docChanged: true };
2134+
sourceEditor?.emit('update', { editor: sourceEditor });
2135+
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 9 });
2136+
2137+
expect(updateSpy).toHaveBeenCalledWith(
2138+
expect.objectContaining({
2139+
editor: expect.any(Object),
2140+
sourceEditor,
2141+
surface: 'header',
2142+
headerId: 'rId-header-default',
2143+
sectionType: 'default',
2144+
}),
2145+
);
2146+
expect(transactionSpy).toHaveBeenCalledWith(
2147+
expect.objectContaining({
2148+
editor: expect.any(Object),
2149+
sourceEditor,
2150+
surface: 'header',
2151+
headerId: 'rId-header-default',
2152+
sectionType: 'default',
2153+
transaction,
2154+
duration: 9,
2155+
}),
2156+
);
2157+
});
2158+
2159+
it('stops re-emitting header/footer child editor events after exiting edit mode', async () => {
2160+
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());
2161+
2162+
editor = new PresentationEditor({
2163+
element: container,
2164+
documentId: 'test-doc',
2165+
});
2166+
2167+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
2168+
await new Promise((resolve) => setTimeout(resolve, 100));
2169+
2170+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
2171+
const mockPage = document.createElement('div');
2172+
mockPage.setAttribute('data-page-index', '0');
2173+
pagesHost.appendChild(mockPage);
2174+
2175+
const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
2176+
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
2177+
left: 0,
2178+
top: 0,
2179+
width: 800,
2180+
height: 1000,
2181+
right: 800,
2182+
bottom: 1000,
2183+
x: 0,
2184+
y: 0,
2185+
toJSON: () => ({}),
2186+
} as DOMRect);
2187+
2188+
const updateSpy = vi.fn();
2189+
const transactionSpy = vi.fn();
2190+
editor.on('headerFooterUpdate', updateSpy);
2191+
editor.on('headerFooterTransaction', transactionSpy);
2192+
2193+
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));
2194+
2195+
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
2196+
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
2197+
2198+
const sourceEditor = editor.getActiveEditor();
2199+
const transaction = { docChanged: true };
2200+
2201+
sourceEditor?.emit('update', { editor: sourceEditor });
2202+
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 9 });
2203+
2204+
expect(updateSpy).toHaveBeenCalledTimes(1);
2205+
expect(transactionSpy).toHaveBeenCalledTimes(1);
2206+
2207+
container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
2208+
await vi.waitFor(() => expect(editor.getActiveEditor()).not.toBe(sourceEditor));
2209+
2210+
sourceEditor?.emit('update', { editor: sourceEditor });
2211+
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 11 });
2212+
2213+
expect(updateSpy).toHaveBeenCalledTimes(1);
2214+
expect(transactionSpy).toHaveBeenCalledTimes(1);
2215+
});
2216+
2217+
it('re-emits live footer child editor updates and transactions', async () => {
2218+
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());
2219+
2220+
editor = new PresentationEditor({
2221+
element: container,
2222+
documentId: 'test-doc',
2223+
});
2224+
2225+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
2226+
await new Promise((resolve) => setTimeout(resolve, 100));
2227+
2228+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
2229+
const mockPage = document.createElement('div');
2230+
mockPage.setAttribute('data-page-index', '0');
2231+
pagesHost.appendChild(mockPage);
2232+
2233+
const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
2234+
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
2235+
left: 0,
2236+
top: 0,
2237+
width: 800,
2238+
height: 1000,
2239+
right: 800,
2240+
bottom: 1000,
2241+
x: 0,
2242+
y: 0,
2243+
toJSON: () => ({}),
2244+
} as DOMRect);
2245+
2246+
const updateSpy = vi.fn();
2247+
const transactionSpy = vi.fn();
2248+
editor.on('headerFooterUpdate', updateSpy);
2249+
editor.on('headerFooterTransaction', transactionSpy);
2250+
2251+
viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }));
2252+
2253+
await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
2254+
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));
2255+
2256+
const sourceEditor = editor.getActiveEditor();
2257+
expect(sourceEditor).toBeDefined();
2258+
2259+
const transaction = { docChanged: true };
2260+
sourceEditor?.emit('update', { editor: sourceEditor });
2261+
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 12 });
2262+
2263+
expect(updateSpy).toHaveBeenCalledWith(
2264+
expect.objectContaining({
2265+
editor: expect.any(Object),
2266+
sourceEditor,
2267+
surface: 'footer',
2268+
headerId: 'rId-footer-default',
2269+
sectionType: 'default',
2270+
}),
2271+
);
2272+
expect(transactionSpy).toHaveBeenCalledWith(
2273+
expect.objectContaining({
2274+
editor: expect.any(Object),
2275+
sourceEditor,
2276+
surface: 'footer',
2277+
headerId: 'rId-footer-default',
2278+
sectionType: 'default',
2279+
transaction,
2280+
duration: 12,
2281+
}),
2282+
);
2283+
});
2284+
20912285
it('clears leftover footer transform when entering footer editing with non-negative minY', async () => {
20922286
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());
20932287

0 commit comments

Comments
 (0)