diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5806eb8d5..c35c32391 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -23,6 +23,7 @@ export type { SuperDocReadyEvent, SuperDocEditorCreateEvent, SuperDocEditorUpdateEvent, + SuperDocTransactionEvent, SuperDocContentErrorEvent, SuperDocExceptionEvent, } from './types'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 4374ef487..d04d1b45b 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -51,9 +51,10 @@ export interface SuperDocEditorCreateEvent { } /** Event passed to onEditorUpdate callback */ -export interface SuperDocEditorUpdateEvent { - editor: Editor; -} +export type SuperDocEditorUpdateEvent = Parameters>[0]; + +/** Event passed to onTransaction callback */ +export type SuperDocTransactionEvent = Parameters>[0]; /** Event passed to onContentError callback */ export interface SuperDocContentErrorEvent { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5fb28224c..03241616e 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -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 diff --git a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts index 8ac7a03fb..6566ffd6c 100644 --- a/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts +++ b/packages/super-editor/src/core/presentation-editor/header-footer/HeaderFooterSessionManager.ts @@ -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; }; // ============================================================================= @@ -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; @@ -415,6 +432,7 @@ export class HeaderFooterSessionManager { resetSession: () => { this.#managerCleanups = []; this.#session = { mode: 'body' }; + this.#teardownActiveEditorEventBridge(); this.#activeEditor = null; this.#deps?.notifyInputBridgeTargetChanged(); }, @@ -640,6 +658,7 @@ export class HeaderFooterSessionManager { this.#activeEditor.setEditable(false); this.#activeEditor.setOptions({ documentMode: 'viewing' }); } + this.#teardownActiveEditorEventBridge(); this.#overlayManager?.hideEditingOverlay(); this.#overlayManager?.showSelectionOverlay(); @@ -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' }; @@ -833,6 +853,7 @@ export class HeaderFooterSessionManager { this.#overlayManager.hideSelectionOverlay(); this.#activeEditor = editor; + this.#setupActiveEditorEventBridge(editor); this.#session = { mode: region.kind, kind: region.kind, @@ -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) { @@ -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') { @@ -1537,6 +1603,8 @@ export class HeaderFooterSessionManager { * Clean up all resources. */ destroy(): void { + this.#teardownActiveEditorEventBridge(); + // Run cleanup functions this.#managerCleanups.forEach((fn) => { try { diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts index 290818c81..b8fdc8a6a 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.test.ts @@ -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()); diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index bcb74c01e..ef4abf19e 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -484,6 +484,102 @@ describe('SuperDoc.vue', () => { expect(presentationEditor.on).toHaveBeenCalledWith('commentPositions', expect.any(Function)); }); + it('forwards header/footer presentation events through the public update callbacks', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.onTransaction = vi.fn(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn(); + + const listeners = {}; + const presentationEditor = { + setContextMenuDisabled: vi.fn(), + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + getCommentBounds: vi.fn(() => ({})), + }; + const bodyEditor = { options: { documentId: 'doc-1' } }; + const sourceEditor = { options: { documentId: 'header-doc' } }; + + wrapper.findComponent(SuperEditorStub).vm.$emit('editor-ready', { + editor: bodyEditor, + presentationEditor, + }); + await nextTick(); + + listeners.headerFooterUpdate({ + editor: bodyEditor, + sourceEditor, + surface: 'header', + headerId: 'rId-header-default', + sectionType: 'default', + }); + expect(superdocStub.emit).toHaveBeenCalledWith('editor-update', { + editor: bodyEditor, + sourceEditor, + surface: 'header', + headerId: 'rId-header-default', + sectionType: 'default', + }); + + const transaction = { docChanged: true, getMeta: vi.fn(() => null) }; + listeners.headerFooterTransaction({ + editor: bodyEditor, + sourceEditor, + transaction, + duration: 12, + surface: 'footer', + headerId: 'rId-footer-default', + sectionType: 'default', + }); + expect(superdocStub.config.onTransaction).toHaveBeenCalledWith({ + editor: bodyEditor, + sourceEditor, + transaction, + duration: 12, + surface: 'footer', + headerId: 'rId-footer-default', + sectionType: 'default', + }); + }); + + it('falls back to sourceEditor for body update and transaction payloads', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.onTransaction = vi.fn(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + const bodyEditor = { options: { documentId: 'doc-1' } }; + const transaction = { docChanged: true, getMeta: vi.fn(() => null) }; + + options.onUpdate({ sourceEditor: bodyEditor }); + expect(superdocStub.emit).toHaveBeenCalledWith('editor-update', { + editor: bodyEditor, + sourceEditor: bodyEditor, + surface: 'body', + headerId: null, + sectionType: null, + }); + + options.onTransaction({ + sourceEditor: bodyEditor, + transaction, + duration: 7, + }); + expect(superdocStub.config.onTransaction).toHaveBeenCalledWith({ + editor: bodyEditor, + sourceEditor: bodyEditor, + transaction, + duration: 7, + surface: 'body', + headerId: null, + sectionType: null, + }); + }); + it('shows comments sidebar and tools, handles menu actions', async () => { const superdocStub = createSuperdocStub(); const wrapper = await mountComponent(superdocStub); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index d7d74d4f2..029486836 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -285,6 +285,14 @@ const onEditorReady = ({ editor, presentationEditor }) => { const totalPages = layout.pages.length; proxy.$superdoc.emit('pagination-update', { totalPages, superdoc: proxy.$superdoc }); }); + + presentationEditor.on('headerFooterUpdate', (payload = {}) => { + proxy.$superdoc.emit('editor-update', buildEditorUpdatePayload(payload)); + }); + + presentationEditor.on('headerFooterTransaction', (payload = {}) => { + emitEditorTransaction(buildEditorTransactionPayload(payload)); + }); }; const onEditorDestroy = () => { @@ -299,8 +307,43 @@ const onEditorDocumentLocked = ({ editor, isLocked, lockedBy }) => { proxy.$superdoc.lockSuperdoc(isLocked, lockedBy); }; -const onEditorUpdate = ({ editor }) => { - proxy.$superdoc.emit('editor-update', { editor }); +const buildEditorPayloadBase = ({ + editor, + sourceEditor, + surface = 'body', + headerId = null, + sectionType = null, +} = {}) => { + const effectiveEditor = editor ?? sourceEditor; + return { + editor: effectiveEditor, + sourceEditor: sourceEditor ?? effectiveEditor, + surface, + headerId, + sectionType, + }; +}; + +const buildEditorUpdatePayload = (payload = {}) => { + return buildEditorPayloadBase(payload); +}; + +const onEditorUpdate = (payload = {}) => { + proxy.$superdoc.emit('editor-update', buildEditorUpdatePayload(payload)); +}; + +const buildEditorTransactionPayload = ({ transaction, duration, ...payload } = {}) => { + return { + ...buildEditorPayloadBase(payload), + transaction, + duration, + }; +}; + +const emitEditorTransaction = (payload = {}) => { + if (typeof proxy.$superdoc.config.onTransaction === 'function') { + proxy.$superdoc.config.onTransaction(payload); + } }; let selectionUpdateRafId = null; @@ -688,7 +731,8 @@ const onEditorCommentsUpdate = (params = {}) => { } }; -const onEditorTransaction = ({ editor, transaction, duration }) => { +const onEditorTransaction = (payload = {}) => { + const { editor, transaction } = payload; const inputType = transaction?.getMeta?.('inputType'); // Call sync on editor transaction but only if it's undo or redo @@ -698,9 +742,7 @@ const onEditorTransaction = ({ editor, transaction, duration }) => { syncTrackedChangePositionsWithDocument({ documentId, editor }); } - if (typeof proxy.$superdoc.config.onTransaction === 'function') { - proxy.$superdoc.config.onTransaction({ editor, transaction, duration }); - } + emitEditorTransaction(buildEditorTransactionPayload(payload)); }; const isCommentsEnabled = computed(() => Boolean(commentsModuleConfig.value)); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 4610b8cbc..17e7707ee 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -173,6 +173,31 @@ * @property {string} [fieldsHighlightColor] - Color for field highlights */ +/** + * @typedef {'body' | 'header' | 'footer'} EditorSurface + * Surface where the edit originated. + */ + +/** + * @typedef {Object} EditorUpdateEvent + * @property {Editor} editor The primary editor associated with the update. For header/footer edits, this is the main body editor. + * @property {Editor} sourceEditor The editor instance that emitted the update. For body edits, this matches `editor`. + * @property {EditorSurface} surface The surface where the edit originated. + * @property {string | null} [headerId] Relationship ID for header/footer edits. + * @property {string | null} [sectionType] Header/footer variant (`default`, `first`, `even`, `odd`) when available. + */ + +/** + * @typedef {Object} EditorTransactionEvent + * @property {Editor} editor The primary editor associated with the transaction. For header/footer edits, this is the main body editor. + * @property {Editor} sourceEditor The editor instance that emitted the transaction. For body edits, this matches `editor`. + * @property {any} transaction The ProseMirror transaction or transaction-like payload emitted by the source editor. + * @property {number} [duration] Time spent applying the transaction, in milliseconds. + * @property {EditorSurface} surface The surface where the transaction originated. + * @property {string | null} [headerId] Relationship ID for header/footer edits. + * @property {string | null} [sectionType] Header/footer variant (`default`, `first`, `even`, `odd`) when available. + */ + /** * @typedef {Object} Config * @property {string} [superdocId] The ID of the SuperDoc @@ -216,7 +241,7 @@ * @property {Object} [layoutEngineOptions.trackedChanges] Optional override for paginated track-changes rendering (e.g., `{ mode: 'final' }` to force final view or `{ enabled: false }` to strip metadata entirely) * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created - * @property {(params: { editor: Editor, transaction: any, duration: number }) => void} [onTransaction] Callback when a transaction is made + * @property {(params: EditorTransactionEvent) => void} [onTransaction] Callback when a transaction is made * @property {() => void} [onEditorDestroy] Callback after an editor is destroyed * @property {(params: { error: object, editor: Editor, documentId: string, file: File }) => void} [onContentError] Callback when there is an error in the content * @property {(editor: { superdoc: SuperDoc }) => void} [onReady] Callback when the SuperDoc is ready @@ -226,7 +251,7 @@ * @property {() => void} [onPdfDocumentReady] Callback when the PDF document is ready * @property {(isOpened: boolean) => void} [onSidebarToggle] Callback when the sidebar is toggled * @property {(params: { editor: Editor }) => void} [onCollaborationReady] Callback when collaboration is ready - * @property {(params: { editor: Editor }) => void} [onEditorUpdate] Callback when document is updated + * @property {(params: EditorUpdateEvent) => void} [onEditorUpdate] Callback when document is updated * @property {(params: { error: Error }) => void} [onException] Callback when an exception is thrown * @property {(params: { isRendered: boolean }) => void} [onCommentsListChange] Callback when the comments list is rendered * @property {(params: { totalPages: number, superdoc: SuperDoc }) => void} [onPaginationUpdate] Callback when pagination layout updates (fires after each layout pass with the current page count)