diff --git a/apps/docs/core/superdoc/events.mdx b/apps/docs/core/superdoc/events.mdx index 8c45aff934..76d2c66347 100644 --- a/apps/docs/core/superdoc/events.mdx +++ b/apps/docs/core/superdoc/events.mdx @@ -337,6 +337,35 @@ superdoc.on('locked', ({ isLocked, lockedBy }) => { ``` +## Pagination events + +### `pagination-update` + +Fired after each layout pass with the current page count. Use this to know when page data is available — `activeEditor.currentTotalPages` is only populated after the first layout completes, which happens *after* the `ready` event. + + +```javascript Usage +superdoc.on('pagination-update', ({ totalPages, superdoc }) => { + console.log(`Document has ${totalPages} pages`); +}); +``` + +```javascript Full Example +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + pagination: true, +}); + +superdoc.on('pagination-update', ({ totalPages, superdoc }) => { + console.log(`Document has ${totalPages} pages`); +}); +``` + + ## UI events ### `zoomChange` @@ -432,6 +461,7 @@ new SuperDoc({ onEditorCreate: ({ editor }) => { }, onEditorUpdate: ({ editor }) => { }, onFontsResolved: ({ documentFonts, unsupportedFonts }) => { }, + onPaginationUpdate: ({ totalPages, superdoc }) => { }, onSidebarToggle: (isOpened) => { }, onException: ({ error }) => { }, }); @@ -443,5 +473,6 @@ new SuperDoc({ 2. `editorCreate` — Editor ready 3. `ready` — All editors ready 4. `collaboration-ready` — If collaboration enabled -5. Runtime events (`editor-update`, `comments-update`, `sidebar-toggle`, etc.) -6. `editorDestroy` — Cleanup +5. `pagination-update` — After each layout pass (page count available) +6. Runtime events (`editor-update`, `comments-update`, `sidebar-toggle`, etc.) +7. `editorDestroy` — Cleanup diff --git a/apps/docs/core/supereditor/methods.mdx b/apps/docs/core/supereditor/methods.mdx index 12bf029bef..1ac5a5553f 100644 --- a/apps/docs/core/supereditor/methods.mdx +++ b/apps/docs/core/supereditor/methods.mdx @@ -860,4 +860,5 @@ editor.commands.goToSearchResult(results[0]); | `isDestroyed` | `boolean` | Whether editor has been destroyed | | `isFocused` | `boolean` | Whether editor has focus | | `docChanged` | `boolean` | Whether any edits have been made | +| `currentTotalPages` | `number \| undefined` | Page count after the first layout completes. `undefined` until then. Use the `pagination-update` event to know when it's available. | | `sourcePath` | `string \| null` | Source file path (null if opened from Blob) | diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 5122123b58..fae9ff7f51 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -224,6 +224,19 @@ export class Editor extends EventEmitter { */ presentationEditor: PresentationEditor | null = null; + /** + * Returns the current total number of pages when pagination is active. + * Delegates to the PresentationEditor's layout state. + * Returns `undefined` before the first layout completes or when pagination is off. + */ + get currentTotalPages(): number | undefined { + if (this.presentationEditor) { + const pages = this.presentationEditor.getPages(); + return pages.length > 0 ? pages.length : undefined; + } + return undefined; + } + /** * Whether the editor currently has focus */ diff --git a/packages/super-editor/src/tests/editor/currentTotalPages.test.js b/packages/super-editor/src/tests/editor/currentTotalPages.test.js new file mode 100644 index 0000000000..f20d9f1dcb --- /dev/null +++ b/packages/super-editor/src/tests/editor/currentTotalPages.test.js @@ -0,0 +1,99 @@ +/* @vitest-environment node */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { Editor } from '@core/Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { createDOMGlobalsLifecycle } from '../helpers/dom-globals-test-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const loadDocxFixture = async (filename) => { + return readFile(join(__dirname, '../data', filename)); +}; + +describe('Editor.currentTotalPages', () => { + const domLifecycle = createDOMGlobalsLifecycle(); + + beforeEach(() => { + domLifecycle.setup(); + }); + + afterEach(() => { + domLifecycle.teardown(); + }); + + it('returns undefined when presentationEditor is null', async () => { + const buffer = await loadDocxFixture('blank-doc.docx'); + const [content, , mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const editor = new Editor({ + mode: 'docx', + documentId: 'test-no-pagination', + extensions: getStarterExtensions(), + content, + mediaFiles, + fonts, + }); + + expect(editor.presentationEditor).toBeNull(); + expect(editor.currentTotalPages).toBeUndefined(); + + editor.destroy(); + }); + + it('returns undefined when presentationEditor has no pages yet', async () => { + const buffer = await loadDocxFixture('blank-doc.docx'); + const [content, , mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const editor = new Editor({ + mode: 'docx', + documentId: 'test-empty-pages', + extensions: getStarterExtensions(), + content, + mediaFiles, + fonts, + }); + + // Simulate a presentationEditor with empty pages (before first layout) + editor.presentationEditor = /** @type {any} */ ({ + getPages: vi.fn(() => []), + }); + + expect(editor.currentTotalPages).toBeUndefined(); + + editor.presentationEditor = null; + editor.destroy(); + }); + + it('returns the page count when presentationEditor has pages', async () => { + const buffer = await loadDocxFixture('blank-doc.docx'); + const [content, , mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const editor = new Editor({ + mode: 'docx', + documentId: 'test-with-pages', + extensions: getStarterExtensions(), + content, + mediaFiles, + fonts, + }); + + // Simulate a presentationEditor after layout completes + editor.presentationEditor = /** @type {any} */ ({ + getPages: vi.fn(() => [ + { number: 1, size: { w: 612, h: 792 } }, + { number: 2, size: { w: 612, h: 792 } }, + { number: 3, size: { w: 612, h: 792 } }, + ]), + }); + + expect(editor.currentTotalPages).toBe(3); + + editor.presentationEditor = null; + editor.destroy(); + }); +}); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index ab9d469ff4..2ae153f7ff 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -280,6 +280,11 @@ const onEditorReady = ({ editor, presentationEditor }) => { hasInitializedLocations.value = true; } }); + + presentationEditor.on('paginationUpdate', ({ layout }) => { + const totalPages = layout.pages.length; + proxy.$superdoc.emit('pagination-update', { totalPages, superdoc: proxy.$superdoc }); + }); }; const onEditorDestroy = () => { diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 20156d0a1a..c08d857032 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -122,6 +122,7 @@ export class SuperDoc extends EventEmitter { onCommentsListChange: () => null, onException: () => null, onListDefinitionsChange: () => null, + onPaginationUpdate: () => null, onTransaction: () => null, onFontsResolved: null, @@ -449,6 +450,7 @@ export class SuperDoc extends EventEmitter { this.on('content-error', this.onContentError); this.on('exception', this.config.onException); this.on('list-definitions-change', this.config.onListDefinitionsChange); + this.on('pagination-update', this.config.onPaginationUpdate); if (this.config.onFontsResolved) { this.on('fonts-resolved', this.config.onFontsResolved); diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index a2f171dc26..fe2bc62175 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -1444,4 +1444,36 @@ describe('SuperDoc core', () => { warnSpy.mockRestore(); }); }); + + describe('pagination-update event', () => { + it('registers onPaginationUpdate listener during init', async () => { + createAppHarness(); + const onPaginationUpdate = vi.fn(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + onPaginationUpdate, + }); + await flushMicrotasks(); + + instance.emit('pagination-update', { totalPages: 5, superdoc: instance }); + expect(onPaginationUpdate).toHaveBeenCalledWith({ totalPages: 5, superdoc: instance }); + }); + + it('defaults onPaginationUpdate to a no-op', async () => { + createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + // Should not throw when emitting without a user callback + expect(() => { + instance.emit('pagination-update', { totalPages: 3, superdoc: instance }); + }).not.toThrow(); + }); + }); }); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 13926dc3e6..f0adbeec4e 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -184,6 +184,7 @@ * @property {(params: { editor: Editor }) => 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) * @property {(params: {})} [onListDefinitionsChange] Callback when the list definitions change * @property {string} [format] The format of the document (docx, pdf, html) * @property {Object[]} [editorExtensions] The extensions to load for the editor