diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 66b40e0e83..f1d2cebc7f 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -998,6 +998,45 @@ 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() { + return this.superdocStore?.activeZoom ?? 100; + } + + /** + * 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% + * 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; + } + + // 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 }); + } + /** * Set the document to locked or unlocked * @param {boolean} lock diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 5c813ba2c6..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(); @@ -1073,6 +1079,286 @@ describe('SuperDoc core', () => { }); }); + describe('Zoom API', () => { + it('getZoom returns 100 by default', async () => { + createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + expect(instance.getZoom()).toBe(100); + }); + + it('getZoom returns current activeZoom from store', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + superdocStore.activeZoom = 150; + expect(instance.getZoom()).toBe(150); + + superdocStore.activeZoom = 75; + expect(instance.getZoom()).toBe(75); + }); + + it('setZoom updates activeZoom in the store', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + instance.setZoom(150); + + 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(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + const zoomChangeSpy = vi.fn(); + instance.on('zoomChange', zoomChangeSpy); + + instance.setZoom(200); + + expect(zoomChangeSpy).toHaveBeenCalledWith({ zoom: 200 }); + }); + + it('getZoom reflects value set by setZoom', async () => { + 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', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + instance.setZoom(75); + expect(instance.getZoom()).toBe(75); + + instance.setZoom(200); + 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(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + 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(superdocStore.activeZoom).toBe(100); + expect(zoomChangeSpy).not.toHaveBeenCalled(); + + warnSpy.mockClear(); + + // Test zero + instance.setZoom(0); + expect(warnSpy).toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); + + warnSpy.mockClear(); + + // Test non-number + instance.setZoom('150'); + expect(warnSpy).toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); + + warnSpy.mockClear(); + + // Test NaN + instance.setZoom(NaN); + expect(warnSpy).toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); + + warnSpy.mockClear(); + + // Test Infinity + instance.setZoom(Infinity); + expect(warnSpy).toHaveBeenCalled(); + expect(superdocStore.activeZoom).toBe(100); + + warnSpy.mockRestore(); + }); + + it('setZoom is consistent with toolbar zoom command', async () => { + const { superdocStore } = createAppHarness(); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + // Programmatic API should update the same store property as the toolbar + instance.setZoom(150); + expect(superdocStore.activeZoom).toBe(150); + + // Simulate toolbar zoom (same path) + instance.onToolbarCommand({ item: { command: 'setZoom' }, argument: 200 }); + expect(superdocStore.activeZoom).toBe(200); + expect(instance.getZoom()).toBe(200); + }); + }); + describe('Web layout mode configuration', () => { it('auto-disables layout engine when web layout is enabled', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 954fe40dd5..8932901ea9 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -499,6 +499,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; @@ -782,6 +786,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; @@ -990,6 +1011,11 @@ if (scrollTestMode.value) { +
+ + {{ currentZoom }}% + +
@@ -1423,6 +1449,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;