Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
SuperDocReadyEvent,
SuperDocEditorCreateEvent,
SuperDocEditorUpdateEvent,
SuperDocTransactionEvent,
SuperDocContentErrorEvent,
SuperDocExceptionEvent,
} from './types';
7 changes: 4 additions & 3 deletions packages/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ export interface SuperDocEditorCreateEvent {
}

/** Event passed to onEditorUpdate callback */
export interface SuperDocEditorUpdateEvent {
editor: Editor;
}
export type SuperDocEditorUpdateEvent = Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0];

/** Event passed to onTransaction callback */
export type SuperDocTransactionEvent = Parameters<NonNullable<SuperDocConstructorConfig['onTransaction']>>[0];

/** Event passed to onContentError callback */
export interface SuperDocContentErrorEvent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3150,6 +3150,26 @@ export class PresentationEditor extends EventEmitter {
onUpdateAwarenessSession: () => {
this.#updateAwarenessSession();
},
onSurfaceUpdate: ({ sourceEditor, surface, headerId, sectionType }) => {
this.emit('headerFooterUpdate', {
editor: this.#editor,
sourceEditor,
surface,
headerId,
sectionType,
});
},
onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => {
this.emit('headerFooterTransaction', {
editor: this.#editor,
sourceEditor,
surface,
headerId,
sectionType,
transaction,
duration,
});
},
});

// Initialize the registry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ export type SessionManagerCallbacks = {
onAnnounce?: (message: string) => void;
/** Called to update awareness session */
onUpdateAwarenessSession?: (session: HeaderFooterSession) => void;
/** Called when the active header/footer editor emits an update */
onSurfaceUpdate?: (data: {
sourceEditor: Editor;
surface: 'header' | 'footer';
headerId?: string | null;
sectionType?: string | null;
}) => void;
/** Called when the active header/footer editor emits a transaction */
onSurfaceTransaction?: (data: {
sourceEditor: Editor;
surface: 'header' | 'footer';
headerId?: string | null;
sectionType?: string | null;
transaction: unknown;
duration?: number;
}) => void;
};

// =============================================================================
Expand Down Expand Up @@ -198,6 +214,7 @@ export class HeaderFooterSessionManager {
// Session state
#session: HeaderFooterSession = { mode: 'body' };
#activeEditor: Editor | null = null;
#activeEditorEventCleanup: (() => void) | null = null;

// Hover UI elements (passed in, not owned)
#hoverOverlay: HTMLElement | null = null;
Expand Down Expand Up @@ -415,6 +432,7 @@ export class HeaderFooterSessionManager {
resetSession: () => {
this.#managerCleanups = [];
this.#session = { mode: 'body' };
this.#teardownActiveEditorEventBridge();
this.#activeEditor = null;
this.#deps?.notifyInputBridgeTargetChanged();
},
Expand Down Expand Up @@ -640,6 +658,7 @@ export class HeaderFooterSessionManager {
this.#activeEditor.setEditable(false);
this.#activeEditor.setOptions({ documentMode: 'viewing' });
}
this.#teardownActiveEditorEventBridge();

this.#overlayManager?.hideEditingOverlay();
this.#overlayManager?.showSelectionOverlay();
Expand Down Expand Up @@ -687,6 +706,7 @@ export class HeaderFooterSessionManager {
this.#activeEditor.setEditable(false);
this.#activeEditor.setOptions({ documentMode: 'viewing' });
}
this.#teardownActiveEditorEventBridge();
this.#overlayManager.hideEditingOverlay();
this.#activeEditor = null;
this.#session = { mode: 'body' };
Expand Down Expand Up @@ -833,6 +853,7 @@ export class HeaderFooterSessionManager {
this.#overlayManager.hideSelectionOverlay();

this.#activeEditor = editor;
this.#setupActiveEditorEventBridge(editor);
this.#session = {
mode: region.kind,
kind: region.kind,
Expand Down Expand Up @@ -861,6 +882,7 @@ export class HeaderFooterSessionManager {
this.#overlayManager?.hideEditingOverlay();
this.#overlayManager?.showSelectionOverlay();
this.clearHover();
this.#teardownActiveEditorEventBridge();
this.#activeEditor = null;
this.#session = { mode: 'body' };
} catch (cleanupError) {
Expand Down Expand Up @@ -928,6 +950,50 @@ export class HeaderFooterSessionManager {
this.#callbacks.onAnnounce?.(message);
}

#setupActiveEditorEventBridge(editor: Editor): void {
this.#teardownActiveEditorEventBridge();

const emitSurfaceUpdate = () => {
if (this.#session.mode !== 'header' && this.#session.mode !== 'footer') return;
this.#callbacks.onSurfaceUpdate?.({
sourceEditor: editor,
surface: this.#session.mode,
headerId: this.#session.headerId ?? null,
sectionType: this.#session.sectionType ?? null,
});
};

const emitSurfaceTransaction = ({ transaction, duration }: { transaction: unknown; duration?: number }) => {
if (this.#session.mode !== 'header' && this.#session.mode !== 'footer') return;
this.#callbacks.onSurfaceTransaction?.({
sourceEditor: editor,
surface: this.#session.mode,
headerId: this.#session.headerId ?? null,
sectionType: this.#session.sectionType ?? null,
transaction,
duration,
});
};

editor.on('update', emitSurfaceUpdate);
editor.on('transaction', emitSurfaceTransaction);

this.#activeEditorEventCleanup = () => {
editor.off?.('update', emitSurfaceUpdate);
editor.off?.('transaction', emitSurfaceTransaction);
};
}

#teardownActiveEditorEventBridge(): void {
try {
this.#activeEditorEventCleanup?.();
} catch (error) {
console.warn('[HeaderFooterSessionManager] Failed to clean up active editor bridge:', error);
} finally {
this.#activeEditorEventCleanup = null;
}
}

#updateModeBanner(): void {
if (!this.#modeBanner) return;
if (this.#session.mode === 'body') {
Expand Down Expand Up @@ -1537,6 +1603,8 @@ export class HeaderFooterSessionManager {
* Clean up all resources.
*/
destroy(): void {
this.#teardownActiveEditorEventBridge();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the resetSession callback in initialize() sets #activeEditor = null without calling #teardownActiveEditorEventBridge() first. every other place that clears #activeEditor does the teardown. not a bug since the editor is already destroyed, but the leftover cleanup ref could log a warning next time around. worth adding for consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good, done


// Run cleanup functions
this.#managerCleanups.forEach((fn) => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2088,6 +2088,200 @@ describe('PresentationEditor', () => {
boundingSpy.mockRestore();
});

it('re-emits live header/footer child editor updates and transactions', async () => {
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());

editor = new PresentationEditor({
element: container,
documentId: 'test-doc',
});

await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 100));

const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
const mockPage = document.createElement('div');
mockPage.setAttribute('data-page-index', '0');
pagesHost.appendChild(mockPage);

const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 1000,
right: 800,
bottom: 1000,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);

const updateSpy = vi.fn();
const transactionSpy = vi.fn();
editor.on('headerFooterUpdate', updateSpy);
editor.on('headerFooterTransaction', transactionSpy);

viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));

await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));

const sourceEditor = editor.getActiveEditor();
expect(sourceEditor).toBeDefined();

const transaction = { docChanged: true };
sourceEditor?.emit('update', { editor: sourceEditor });
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 9 });

expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
sourceEditor,
surface: 'header',
headerId: 'rId-header-default',
sectionType: 'default',
}),
);
expect(transactionSpy).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
sourceEditor,
surface: 'header',
headerId: 'rId-header-default',
sectionType: 'default',
transaction,
duration: 9,
}),
);
});

it('stops re-emitting header/footer child editor events after exiting edit mode', async () => {
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());

editor = new PresentationEditor({
element: container,
documentId: 'test-doc',
});

await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 100));

const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
const mockPage = document.createElement('div');
mockPage.setAttribute('data-page-index', '0');
pagesHost.appendChild(mockPage);

const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 1000,
right: 800,
bottom: 1000,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);

const updateSpy = vi.fn();
const transactionSpy = vi.fn();
editor.on('headerFooterUpdate', updateSpy);
editor.on('headerFooterTransaction', transactionSpy);

viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 50, button: 0 }));

await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));

const sourceEditor = editor.getActiveEditor();
const transaction = { docChanged: true };

sourceEditor?.emit('update', { editor: sourceEditor });
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 9 });

expect(updateSpy).toHaveBeenCalledTimes(1);
expect(transactionSpy).toHaveBeenCalledTimes(1);

container.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(editor.getActiveEditor()).not.toBe(sourceEditor));

sourceEditor?.emit('update', { editor: sourceEditor });
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 11 });

expect(updateSpy).toHaveBeenCalledTimes(1);
expect(transactionSpy).toHaveBeenCalledTimes(1);
});

it('re-emits live footer child editor updates and transactions', async () => {
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());

editor = new PresentationEditor({
element: container,
documentId: 'test-doc',
});

await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 100));

const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
const mockPage = document.createElement('div');
mockPage.setAttribute('data-page-index', '0');
pagesHost.appendChild(mockPage);

const viewport = container.querySelector('.presentation-editor__viewport') as HTMLElement;
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 1000,
right: 800,
bottom: 1000,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);

const updateSpy = vi.fn();
const transactionSpy = vi.fn();
editor.on('headerFooterUpdate', updateSpy);
editor.on('headerFooterTransaction', transactionSpy);

viewport.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: 120, clientY: 740, button: 0 }));

await vi.waitFor(() => expect(createdSectionEditors.length).toBeGreaterThan(0));
await vi.waitFor(() => expect(editor.getActiveEditor()).toBe(createdSectionEditors.at(-1)?.editor));

const sourceEditor = editor.getActiveEditor();
expect(sourceEditor).toBeDefined();

const transaction = { docChanged: true };
sourceEditor?.emit('update', { editor: sourceEditor });
sourceEditor?.emit('transaction', { editor: sourceEditor, transaction, duration: 12 });

expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
sourceEditor,
surface: 'footer',
headerId: 'rId-footer-default',
sectionType: 'default',
}),
);
expect(transactionSpy).toHaveBeenCalledWith(
expect.objectContaining({
editor: expect.any(Object),
sourceEditor,
surface: 'footer',
headerId: 'rId-footer-default',
sectionType: 'default',
transaction,
duration: 12,
}),
);
});

it('clears leftover footer transform when entering footer editing with non-negative minY', async () => {
mockIncrementalLayout.mockResolvedValueOnce(buildLayoutResult());

Expand Down
Loading
Loading