Skip to content
Merged
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
39 changes: 39 additions & 0 deletions packages/superdoc/src/core/SuperDoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
286 changes: 286 additions & 0 deletions packages/superdoc/src/core/SuperDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ vi.mock('uuid', () => ({

const toolbarUpdateSpy = vi.fn();
const toolbarSetActiveSpy = vi.fn();
const toolbarSetZoomSpy = vi.fn();

class MockToolbar {
constructor(config) {
Expand All @@ -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 }));
Expand Down Expand Up @@ -172,6 +177,7 @@ describe('SuperDoc core', () => {
vi.resetModules();
toolbarUpdateSpy.mockClear();
toolbarSetActiveSpy.mockClear();
toolbarSetZoomSpy.mockClear();
createZipMock.mockClear();
createDownloadMock.mockClear();
cleanNameMock.mockClear();
Expand Down Expand Up @@ -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(() => {});
Expand Down
Loading
Loading