From 557969e1e49d8cfdb2f1eaad394139c9f688c028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1=2E?= Date: Fri, 12 Dec 2025 10:33:20 -0300 Subject: [PATCH 1/4] feat: expose setZoom and getZoom API on SuperDoc Add public methods to programmatically control zoom level: - `setZoom(percent)` - Set zoom level (e.g., 150 for 150%) - `getZoom()` - Get current zoom level as percentage This allows developers to implement custom zoom controls without relying on the toolbar UI. Usage: ```javascript // Set zoom to 150% superdoc.setZoom(150); // Get current zoom const zoom = superdoc.getZoom(); // Returns 100, 150, etc. // Listen for zoom changes superdoc.on('zoomChange', ({ zoom }) => { console.log(`Zoom changed to ${zoom}%`); }); ``` Closes #928 --- packages/superdoc/src/core/SuperDoc.js | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index c6abffa094..5639db8e8b 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -816,6 +816,49 @@ export class SuperDoc extends EventEmitter { return this.activeEditor?.commands.goToSearchResult(match); } + /** + * Get the current zoom level as a percentage (e.g., 100 for 100%) + * @returns {number} The current zoom level as a percentage + * @example + * const zoom = superdoc.getZoom(); // Returns 100, 150, 200, etc. + */ + getZoom() { + const doc = this.superdocStore?.documents?.[0]; + const presentationEditor = typeof doc?.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; + if (presentationEditor && typeof presentationEditor.zoom === 'number') { + return Math.round(presentationEditor.zoom * 100); + } + // Fallback to 100% if no presentation editor + return 100; + } + + /** + * Set the zoom level for all documents + * @param {number} percent - The zoom level as a percentage (e.g., 100, 150, 200) + * @example + * superdoc.setZoom(150); // Set zoom to 150% + * superdoc.setZoom(50); // Set zoom to 50% + */ + setZoom(percent) { + if (typeof percent !== 'number' || !Number.isFinite(percent) || percent <= 0) { + console.warn('[SuperDoc] setZoom expects a positive number representing percentage'); + return; + } + + const zoomMultiplier = percent / 100; + + // Update all presentation editors + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = typeof doc?.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; + if (presentationEditor && typeof presentationEditor.setZoom === 'function') { + presentationEditor.setZoom(zoomMultiplier); + } + }); + + // Emit zoom change event + this.emit('zoomChange', { zoom: percent }); + } + /** * Set the document to locked or unlocked * @param {boolean} lock From 84f915cdca80e4daaec81cbb4114192137a2887f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeu=20Tupinamb=C3=A1=2E?= Date: Fri, 12 Dec 2025 10:37:19 -0300 Subject: [PATCH 2/4] test: add comprehensive tests for setZoom and getZoom API Add 8 test cases covering: - getZoom returns 100 when no presentation editor is available - getZoom returns correct percentage from presentation editor - getZoom rounds to nearest integer - setZoom calls presentation editor with correct multiplier - setZoom emits zoomChange event - setZoom updates all editors in multi-document mode - setZoom handles invalid values gracefully - setZoom handles missing presentation editor --- packages/superdoc/src/core/SuperDoc.test.js | 276 ++++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 3b03ff1d57..0810099f39 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -792,4 +792,280 @@ describe('SuperDoc core', () => { }); }); }); + + describe('Zoom API', () => { + it('getZoom returns 100 when no presentation editor is available', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => null), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + expect(instance.getZoom()).toBe(100); + }); + + it('getZoom returns correct percentage from presentation editor', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { + zoom: 1.5, // 150% + setZoom: vi.fn(), + }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + expect(instance.getZoom()).toBe(150); + }); + + it('getZoom rounds to nearest integer', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { + zoom: 0.333, // 33.3% + setZoom: vi.fn(), + }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + expect(instance.getZoom()).toBe(33); + }); + + it('setZoom calls presentation editor setZoom with correct multiplier', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { + zoom: 1, + setZoom: vi.fn(), + }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + instance.setZoom(150); + + expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.5); + }); + + it('setZoom emits zoomChange event', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { + zoom: 1, + setZoom: vi.fn(), + }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + const zoomChangeSpy = vi.fn(); + instance.on('zoomChange', zoomChangeSpy); + + instance.setZoom(200); + + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 200 }); + }); + + it('setZoom updates all presentation editors in multi-document mode', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor1 = { zoom: 1, setZoom: vi.fn() }; + const mockPresentationEditor2 = { zoom: 1, setZoom: vi.fn() }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor1), + }, + { + id: 'doc-2', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor2), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + instance.setZoom(75); + + expect(mockPresentationEditor1.setZoom).toHaveBeenCalledWith(0.75); + expect(mockPresentationEditor2.setZoom).toHaveBeenCalledWith(0.75); + }); + + it('setZoom warns and returns early for invalid values', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + const zoomChangeSpy = vi.fn(); + instance.on('zoomChange', zoomChangeSpy); + + // Test negative value + instance.setZoom(-50); + expect(warnSpy).toHaveBeenCalledWith('[SuperDoc] setZoom expects a positive number representing percentage'); + expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(zoomChangeSpy).not.toHaveBeenCalled(); + + warnSpy.mockClear(); + mockPresentationEditor.setZoom.mockClear(); + + // Test zero + instance.setZoom(0); + expect(warnSpy).toHaveBeenCalled(); + expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + + warnSpy.mockClear(); + mockPresentationEditor.setZoom.mockClear(); + + // Test non-number + instance.setZoom('150'); + expect(warnSpy).toHaveBeenCalled(); + expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + + warnSpy.mockClear(); + mockPresentationEditor.setZoom.mockClear(); + + // Test NaN + instance.setZoom(NaN); + expect(warnSpy).toHaveBeenCalled(); + expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + + warnSpy.mockClear(); + mockPresentationEditor.setZoom.mockClear(); + + // Test Infinity + instance.setZoom(Infinity); + expect(warnSpy).toHaveBeenCalled(); + expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('setZoom handles documents without presentation editor gracefully', async () => { + const { superdocStore } = createAppHarness(); + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => null), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + const zoomChangeSpy = vi.fn(); + instance.on('zoomChange', zoomChangeSpy); + + // Should not throw + expect(() => instance.setZoom(150)).not.toThrow(); + + // Event should still be emitted + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 150 }); + }); + }); }); From 94cd9c5942227a6c3572962de2b668d53badb8d7 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Feb 2026 13:31:09 -0300 Subject: [PATCH 3/4] fix(zoom): use centralized activeZoom store for setZoom/getZoom API setZoom was directly calling presentationEditor.setZoom() per document, bypassing superdocStore.activeZoom. This caused overlays, PDF viewer, and whiteboard to desync since the Vue watcher on activeZoom never fired. Now setZoom updates the store (same path as toolbar zoom), and getZoom reads from the store. Also adds zoom +/- buttons to the dev app. --- packages/superdoc/src/core/SuperDoc.js | 25 +-- packages/superdoc/src/core/SuperDoc.test.js | 184 +++--------------- .../src/dev/components/SuperdocDev.vue | 47 +++++ 3 files changed, 81 insertions(+), 175 deletions(-) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index b2e915833b..fae678231b 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1005,17 +1005,13 @@ export class SuperDoc extends EventEmitter { * const zoom = superdoc.getZoom(); // Returns 100, 150, 200, etc. */ getZoom() { - const doc = this.superdocStore?.documents?.[0]; - const presentationEditor = typeof doc?.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; - if (presentationEditor && typeof presentationEditor.zoom === 'number') { - return Math.round(presentationEditor.zoom * 100); - } - // Fallback to 100% if no presentation editor - return 100; + return this.superdocStore?.activeZoom ?? 100; } /** - * Set the zoom level for all documents + * Set the zoom level for all documents. + * Updates the centralized activeZoom state, which propagates to all + * presentation editors, PDF viewers, and whiteboard layers via the Vue watcher. * @param {number} percent - The zoom level as a percentage (e.g., 100, 150, 200) * @example * superdoc.setZoom(150); // Set zoom to 150% @@ -1027,17 +1023,10 @@ export class SuperDoc extends EventEmitter { return; } - const zoomMultiplier = percent / 100; - - // Update all presentation editors - this.superdocStore?.documents?.forEach((doc) => { - const presentationEditor = typeof doc?.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; - if (presentationEditor && typeof presentationEditor.setZoom === 'function') { - presentationEditor.setZoom(zoomMultiplier); - } - }); + if (this.superdocStore) { + this.superdocStore.activeZoom = percent; + } - // Emit zoom change event this.emit('zoomChange', { zoom: percent }); } diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index d2205800c8..3a52cd863a 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -1074,137 +1074,54 @@ describe('SuperDoc core', () => { }); describe('Zoom API', () => { - it('getZoom returns 100 when no presentation editor is available', async () => { - const { superdocStore } = createAppHarness(); - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => null), - }, - ]; + it('getZoom returns 100 by default', async () => { + createAppHarness(); const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); expect(instance.getZoom()).toBe(100); }); - it('getZoom returns correct percentage from presentation editor', async () => { + it('getZoom returns current activeZoom from store', async () => { const { superdocStore } = createAppHarness(); - const mockPresentationEditor = { - zoom: 1.5, // 150% - setZoom: vi.fn(), - }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor), - }, - ]; const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); + superdocStore.activeZoom = 150; expect(instance.getZoom()).toBe(150); - }); - - it('getZoom rounds to nearest integer', async () => { - const { superdocStore } = createAppHarness(); - const mockPresentationEditor = { - zoom: 0.333, // 33.3% - setZoom: vi.fn(), - }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor), - }, - ]; - const instance = new SuperDoc({ - selector: '#host', - document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, - }); - await flushMicrotasks(); - - expect(instance.getZoom()).toBe(33); + superdocStore.activeZoom = 75; + expect(instance.getZoom()).toBe(75); }); - it('setZoom calls presentation editor setZoom with correct multiplier', async () => { + it('setZoom updates activeZoom in the store', async () => { const { superdocStore } = createAppHarness(); - const mockPresentationEditor = { - zoom: 1, - setZoom: vi.fn(), - }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor), - }, - ]; const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); instance.setZoom(150); - expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.5); + expect(superdocStore.activeZoom).toBe(150); }); it('setZoom emits zoomChange event', async () => { - const { superdocStore } = createAppHarness(); - const mockPresentationEditor = { - zoom: 1, - setZoom: vi.fn(), - }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor), - }, - ]; + createAppHarness(); const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); @@ -1216,60 +1133,29 @@ describe('SuperDoc core', () => { expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 200 }); }); - it('setZoom updates all presentation editors in multi-document mode', async () => { - const { superdocStore } = createAppHarness(); - const mockPresentationEditor1 = { zoom: 1, setZoom: vi.fn() }; - const mockPresentationEditor2 = { zoom: 1, setZoom: vi.fn() }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor1), - }, - { - id: 'doc-2', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor2), - }, - ]; + it('getZoom reflects value set by setZoom', async () => { + createAppHarness(); const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); instance.setZoom(75); + expect(instance.getZoom()).toBe(75); - expect(mockPresentationEditor1.setZoom).toHaveBeenCalledWith(0.75); - expect(mockPresentationEditor2.setZoom).toHaveBeenCalledWith(0.75); + instance.setZoom(200); + expect(instance.getZoom()).toBe(200); }); it('setZoom warns and returns early for invalid values', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { superdocStore } = createAppHarness(); - const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() }; - - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => mockPresentationEditor), - }, - ]; const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); @@ -1279,73 +1165,57 @@ describe('SuperDoc core', () => { // Test negative value instance.setZoom(-50); expect(warnSpy).toHaveBeenCalledWith('[SuperDoc] setZoom expects a positive number representing percentage'); - expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); expect(zoomChangeSpy).not.toHaveBeenCalled(); warnSpy.mockClear(); - mockPresentationEditor.setZoom.mockClear(); // Test zero instance.setZoom(0); expect(warnSpy).toHaveBeenCalled(); - expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); warnSpy.mockClear(); - mockPresentationEditor.setZoom.mockClear(); // Test non-number instance.setZoom('150'); expect(warnSpy).toHaveBeenCalled(); - expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); warnSpy.mockClear(); - mockPresentationEditor.setZoom.mockClear(); // Test NaN instance.setZoom(NaN); expect(warnSpy).toHaveBeenCalled(); - expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); warnSpy.mockClear(); - mockPresentationEditor.setZoom.mockClear(); // Test Infinity instance.setZoom(Infinity); expect(warnSpy).toHaveBeenCalled(); - expect(mockPresentationEditor.setZoom).not.toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); warnSpy.mockRestore(); }); - it('setZoom handles documents without presentation editor gracefully', async () => { + it('setZoom is consistent with toolbar zoom command', async () => { const { superdocStore } = createAppHarness(); - superdocStore.documents = [ - { - id: 'doc-1', - type: DOCX, - getPresentationEditor: vi.fn(() => null), - }, - ]; - const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', - documents: [], - modules: { comments: {}, toolbar: {} }, - colors: ['red'], - user: { name: 'Jane', email: 'jane@example.com' }, }); await flushMicrotasks(); - const zoomChangeSpy = vi.fn(); - instance.on('zoomChange', zoomChangeSpy); - - // Should not throw - expect(() => instance.setZoom(150)).not.toThrow(); + // Programmatic API should update the same store property as the toolbar + instance.setZoom(150); + expect(superdocStore.activeZoom).toBe(150); - // Event should still be emitted - expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 150 }); + // Simulate toolbar zoom (same path) + instance.onToolbarCommand({ item: { command: 'setZoom' }, argument: 200 }); + expect(superdocStore.activeZoom).toBe(200); + expect(instance.getZoom()).toBe(200); }); }); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 211c26f91e..9c14b2e600 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -488,6 +488,10 @@ const init = async () => { console.error('SuperDoc exception:', error); }); + superdoc.value?.on('zoomChange', ({ zoom }) => { + currentZoom.value = zoom; + }); + window.superdoc = superdoc.value; // const ydoc = superdoc.value.ydoc; @@ -771,6 +775,23 @@ const toggleViewLayout = () => { window.location.href = url.toString(); }; +const currentZoom = ref(100); +const ZOOM_STEP = 10; +const ZOOM_MIN = 25; +const ZOOM_MAX = 400; + +const zoomIn = () => { + const next = Math.min(ZOOM_MAX, currentZoom.value + ZOOM_STEP); + currentZoom.value = next; + superdoc.value?.setZoom(next); +}; + +const zoomOut = () => { + const next = Math.max(ZOOM_MIN, currentZoom.value - ZOOM_STEP); + currentZoom.value = next; + superdoc.value?.setZoom(next); +}; + const showExportMenu = ref(false); const closeExportMenu = () => { showExportMenu.value = false; @@ -979,6 +1000,11 @@ if (scrollTestMode.value) { +
+ + {{ currentZoom }}% + +
@@ -1412,6 +1438,27 @@ if (scrollTestMode.value) { box-shadow: none; } +.dev-app__zoom-controls { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dev-app__zoom-controls .dev-app__header-export-btn { + min-width: 32px; + padding: 6px 8px; + font-size: 16px; + font-weight: 600; +} + +.dev-app__zoom-label { + color: #e2e8f0; + font-size: 13px; + min-width: 42px; + text-align: center; + user-select: none; +} + .dev-app__dropdown { position: relative; display: inline-flex; From 32459e6b7ef6d31e3bca9bf4480fc44ca606a6a5 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 20 Feb 2026 17:55:03 -0800 Subject: [PATCH 4/4] fix(superdoc): prevent duplicate zoom application and sync toolbar zoom state --- packages/superdoc/src/core/SuperDoc.js | 7 + packages/superdoc/src/core/SuperDoc.test.js | 142 +++++++++++++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index fae678231b..f1d2cebc7f 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1023,10 +1023,17 @@ export class SuperDoc extends EventEmitter { return; } + // Update store — SuperDoc.vue's activeZoom watcher propagates the zoom + // to all PresentationEditor instances via PresentationEditor.setGlobalZoom(). if (this.superdocStore) { this.superdocStore.activeZoom = percent; } + // Update toolbar UI so the dropdown label reflects the new zoom level + if (this.toolbar && typeof this.toolbar.setZoom === 'function') { + this.toolbar.setZoom(percent); + } + this.emit('zoomChange', { zoom: percent }); } diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 3a52cd863a..a2f171dc26 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -16,6 +16,7 @@ vi.mock('uuid', () => ({ const toolbarUpdateSpy = vi.fn(); const toolbarSetActiveSpy = vi.fn(); +const toolbarSetZoomSpy = vi.fn(); class MockToolbar { constructor(config) { @@ -37,6 +38,10 @@ class MockToolbar { this.activeEditor = editor; toolbarSetActiveSpy(editor); } + + setZoom(percent) { + toolbarSetZoomSpy(percent); + } } const createZipMock = vi.fn(async (blobs, names) => ({ zip: true, blobs, names })); @@ -172,6 +177,7 @@ describe('SuperDoc core', () => { vi.resetModules(); toolbarUpdateSpy.mockClear(); toolbarSetActiveSpy.mockClear(); + toolbarSetZoomSpy.mockClear(); createZipMock.mockClear(); createDownloadMock.mockClear(); cleanNameMock.mockClear(); @@ -1116,6 +1122,52 @@ describe('SuperDoc core', () => { expect(superdocStore.activeZoom).toBe(150); }); + it('setZoom propagates multiplier through activeZoom watcher', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { + zoom: 1, + setZoom: vi.fn(), + }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + // Simulate SuperDoc.vue's activeZoom watcher + let activeZoom = 100; + Object.defineProperty(superdocStore, 'activeZoom', { + configurable: true, + get: () => activeZoom, + set: (value) => { + activeZoom = value; + const zoomMultiplier = (value ?? 100) / 100; + superdocStore.documents.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setZoom?.(zoomMultiplier); + }); + }, + }); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + instance.setZoom(150); + + expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.5); + expect(superdocStore.activeZoom).toBe(150); + }); + it('setZoom emits zoomChange event', async () => { createAppHarness(); @@ -1134,7 +1186,22 @@ describe('SuperDoc core', () => { }); it('getZoom reflects value set by setZoom', async () => { - createAppHarness(); + const { superdocStore } = createAppHarness(); + + // Simulate SuperDoc.vue's activeZoom watcher + let activeZoom = 100; + Object.defineProperty(superdocStore, 'activeZoom', { + configurable: true, + get: () => activeZoom, + set: (value) => { + activeZoom = value; + const zoomMultiplier = (value ?? 100) / 100; + superdocStore.documents.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setZoom?.(zoomMultiplier); + }); + }, + }); const instance = new SuperDoc({ selector: '#host', @@ -1149,6 +1216,79 @@ describe('SuperDoc core', () => { expect(instance.getZoom()).toBe(200); }); + it('setZoom avoids duplicate presentation-editor updates when activeZoom store watcher also applies zoom', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + // Simulate SuperDoc.vue's activeZoom watcher: + // watch(activeZoom, zoom => PresentationEditor.setGlobalZoom(zoom / 100)) + let activeZoom = 100; + Object.defineProperty(superdocStore, 'activeZoom', { + configurable: true, + get: () => activeZoom, + set: (value) => { + activeZoom = value; + const zoomMultiplier = (value ?? 100) / 100; + superdocStore.documents.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + presentationEditor?.setZoom?.(zoomMultiplier); + }); + }, + }); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + + instance.setZoom(125); + + expect(mockPresentationEditor.setZoom).toHaveBeenCalledTimes(1); + expect(mockPresentationEditor.setZoom).toHaveBeenCalledWith(1.25); + }); + + it('setZoom updates toolbar zoom UI for programmatic calls', async () => { + const { superdocStore } = createAppHarness(); + const mockPresentationEditor = { zoom: 1, setZoom: vi.fn() }; + + superdocStore.documents = [ + { + id: 'doc-1', + type: DOCX, + getPresentationEditor: vi.fn(() => mockPresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + colors: ['red'], + user: { name: 'Jane', email: 'jane@example.com' }, + }); + await flushMicrotasks(); + toolbarSetZoomSpy.mockClear(); + + instance.setZoom(140); + + expect(toolbarSetZoomSpy).toHaveBeenCalledWith(140); + expect(toolbarSetZoomSpy).toHaveBeenCalledTimes(1); + }); + it('setZoom warns and returns early for invalid values', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { superdocStore } = createAppHarness();