diff --git a/apps/docs/advanced/headless-toolbar.mdx b/apps/docs/advanced/headless-toolbar.mdx
index 0be39ee79a..2ccfd7670b 100644
--- a/apps/docs/advanced/headless-toolbar.mdx
+++ b/apps/docs/advanced/headless-toolbar.mdx
@@ -313,6 +313,7 @@ Snapshot values match the format you pass to `execute()`. What you read is what
| `redo` | — | — |
| `ruler` | — | — |
| `zoom` | number (e.g. `125`) | number |
+| `zoom-fit-width` | none | none |
| `document-mode` | `'editing'` \| `'suggesting'` \| `'viewing'` | mode string |
### Track changes
diff --git a/apps/docs/editor/custom-ui/api-reference.mdx b/apps/docs/editor/custom-ui/api-reference.mdx
index b77ad697c4..b17a6bd77b 100644
--- a/apps/docs/editor/custom-ui/api-reference.mdx
+++ b/apps/docs/editor/custom-ui/api-reference.mdx
@@ -187,6 +187,19 @@ await ui.document.export({ exportType: ['docx'], commentsType: 'external', trigg
await ui.document.replaceFile(file);
```
+### `ui.zoom`
+
+Zoom state, viewport metrics, and the two mutations. The snapshot updates on value changes, mode-only transitions, and viewport metric updates.
+
+```ts
+ui.zoom.getSnapshot(); // { mode, value, fitZoom, min, max, metrics }
+ui.zoom.observe((snapshot) => {});
+ui.zoom.set(125); // numeric zoom; switches the host to manual mode
+ui.zoom.setMode('fit-width'); // continuous fit to the available width
+```
+
+In React, `useSuperDocZoom()` returns the same snapshot plus bound `set` / `setMode` actions. The toolbar registry also exposes a `zoom-fit-width` toggle command for custom toolbars.
+
### `ui.selection`
Live slice, capture, restore, painted geometry.
diff --git a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx
index 4c307f97dc..113f37fc09 100644
--- a/apps/docs/editor/custom-ui/toolbar-and-commands.mdx
+++ b/apps/docs/editor/custom-ui/toolbar-and-commands.mdx
@@ -117,7 +117,7 @@ Common ids you'll wire to buttons:
| Style | `linked-style`, `clear-formatting`, `copy-format` |
| History | `undo`, `redo` |
| Tracked changes | `track-changes-accept-selection`, `track-changes-reject-selection` |
-| View | `ruler`, `zoom`, `document-mode` |
+| View | `ruler`, `zoom`, `zoom-fit-width`, `document-mode` |
| Tables | `table-insert`, `table-add-row-before`, `table-add-row-after`, `table-delete-row`, `table-add-column-before`, `table-add-column-after`, `table-delete-column`, `table-merge-cells`, `table-split-cell`, `table-delete` |
| Insert | `image` |
diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx
index 51848528aa..05ccbcdafb 100644
--- a/apps/docs/editor/superdoc/configuration.mdx
+++ b/apps/docs/editor/superdoc/configuration.mdx
@@ -561,6 +561,68 @@ new SuperDoc({
+
+ Zoom behavior for the document. Use `mode: 'fit-width'` to keep DOCX and PDF documents fitted to the available container width. Calling `setZoom()` switches back to manual zoom.
+
+
+
+ Initial zoom level as a percentage. In `fit-width` mode, this is the paint zoom until the first fit computes.
+
+
+ Starting zoom mode. `'manual'` holds the current value. `'fit-width'` keeps the document fitted to the container.
+
+
+ Bounds and padding for the applied fit-width zoom.
+
+
+ Lower bound for the applied zoom percentage.
+
+
+ Upper bound for the applied zoom percentage. The default never enlarges the document past its natural size; raise it to let wide containers scale the page up.
+
+
+ Horizontal padding in pixels reserved inside the available width before computing the fit.
+
+
+
+
+
+ For custom behavior, listen to [`viewport-change`](/editor/superdoc/events#viewport-change) and call `setZoom()` yourself.
+
+
+
+ ```javascript Usage
+ const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: file,
+ zoom: {
+ mode: 'fit-width',
+ fitWidth: { min: 35, max: 100, padding: 24 },
+ },
+ });
+ ```
+
+ ```javascript Full Example
+ import { SuperDoc } from 'superdoc';
+ import 'superdoc/style.css';
+
+ const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: yourFile,
+ zoom: {
+ initial: 100,
+ mode: 'fit-width',
+ fitWidth: { min: 35, max: 100, padding: 24 },
+ },
+ onZoomChange: ({ zoom, mode }) => {
+ console.log(`Zoom is now ${zoom}% (${mode})`);
+ },
+ });
+ ```
+
+
+
+
**Removed in v1.0**: Use `viewOptions.layout` instead. `'paginated'` → `'print'`, `'responsive'` → `'web'`.
@@ -684,6 +746,26 @@ All handlers are optional functions in the configuration:
```
+
+ Called when zoom changes from `setZoom()`, the toolbar zoom control, or `fit-width` mode.
+
+ ```javascript
+ onZoomChange: ({ zoom, mode }) => {
+ setZoomIndicator(zoom, mode);
+ }
+ ```
+
+
+
+ Called when the fit-width calculation changes. Pixel-level width jitter is deduped. `getViewportMetrics()` always reads the latest measurements.
+
+ ```javascript
+ onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => {
+ updateFitIndicator({ availableWidth, documentWidth, fitZoom });
+ }
+ ```
+
+
Custom handler for accepting tracked changes from comment bubbles. Replaces default accept behavior when provided.
diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx
index 3cffc4de66..1a9416a6a0 100644
--- a/apps/docs/editor/superdoc/events.mdx
+++ b/apps/docs/editor/superdoc/events.mdx
@@ -440,12 +440,12 @@ superdoc.on('pagination-update', ({ totalPages, superdoc }) => {
### `zoomChange`
-When the zoom level changes via `setZoom()`.
+When the zoom level changes, from any source: `setZoom()`, the toolbar zoom control, or [`fit-width` mode](/editor/superdoc/configuration#param-zoom). The payload carries the value and the mode that produced it. Also available as the `onZoomChange` config callback.
```javascript Usage
-superdoc.on('zoomChange', ({ zoom }) => {
- console.log(`Zoom: ${zoom}%`);
+superdoc.on('zoomChange', ({ zoom, mode }) => {
+ console.log(`Zoom: ${zoom}% (${mode})`);
});
```
@@ -458,8 +458,41 @@ const superdoc = new SuperDoc({
document: yourFile,
});
-superdoc.on('zoomChange', ({ zoom }) => {
- console.log(`Zoom: ${zoom}%`);
+superdoc.on('zoomChange', ({ zoom, mode }) => {
+ console.log(`Zoom: ${zoom}% (${mode})`);
+});
+```
+
+
+### `viewport-change`
+
+When the fit-width calculation changes. Pixel-level width changes that do not affect the rounded fit are deduped. `getViewportMetrics()` always reads the latest measurements.
+
+- `availableWidth` - container width in pixels, minus the comments sidebar when visible
+- `documentWidth` - the widest document page width in pixels at 100% zoom (zoom-independent; DOCX from laid-out pages with page-styles fallback, PDF from rendered pages)
+- `fitZoom` - the unclamped zoom percentage that fits the page into the available width
+
+HTML documents reflow to the container, so an HTML-only instance reports no metrics.
+
+For most use cases, prefer [`zoom.mode: 'fit-width'`](/editor/superdoc/configuration#param-zoom). Subscribe to this event only when you want to apply custom zoom behavior.
+
+
+```javascript Usage
+superdoc.on('viewport-change', ({ fitZoom }) => {
+ superdoc.setZoom(Math.min(100, Math.max(35, fitZoom)));
+});
+```
+
+```javascript Full Example
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: yourFile,
+ onViewportChange: ({ availableWidth, documentWidth, fitZoom }) => {
+ console.log(`Need ${fitZoom}% to fit ${documentWidth}px into ${availableWidth}px`);
+ },
});
```
diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx
index 3e807a5000..202f3ce838 100644
--- a/apps/docs/editor/superdoc/methods.mdx
+++ b/apps/docs/editor/superdoc/methods.mdx
@@ -538,7 +538,7 @@ const superdoc = new SuperDoc({
### `setZoom`
-Set the zoom level for all documents. Propagates to all presentation editors, PDF viewers, and whiteboard layers.
+Set an explicit zoom level and switch zoom mode to `manual`. Use `setZoomMode('fit-width')` to turn automatic fitting back on.
Zoom level as a percentage (e.g., `100`, `150`, `200`). Must be a positive finite number.
@@ -560,8 +560,8 @@ const superdoc = new SuperDoc({
onReady: ({ superdoc }) => {
superdoc.setZoom(150);
- superdoc.on('zoomChange', ({ zoom }) => {
- console.log(`Zoom changed to ${zoom}%`);
+ superdoc.on('zoomChange', ({ zoom, mode }) => {
+ console.log(`Zoom changed to ${zoom}% (${mode})`);
});
},
});
@@ -569,6 +569,97 @@ const superdoc = new SuperDoc({
+### `setZoomMode`
+
+Switch between manual zoom and automatic fit-width zoom. `'fit-width'` keeps DOCX and PDF documents fitted to the available container width, using the bounds from [`zoom.fitWidth`](/editor/superdoc/configuration#param-zoom). Calling `setZoom()` switches back to `'manual'`.
+
+
+ The zoom mode to switch to.
+
+
+
+
+```javascript Usage
+superdoc.setZoomMode('fit-width');
+```
+
+```javascript Full Example
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: yourFile,
+ onReady: ({ superdoc }) => {
+ superdoc.setZoomMode('fit-width');
+
+ superdoc.on('zoomChange', ({ zoom, mode }) => {
+ console.log(`Zoom: ${zoom}% (${mode})`);
+ });
+ },
+});
+```
+
+
+
+### `getZoomState`
+
+Get the current zoom mode, value, latest fit calculation, and effective fit bounds.
+
+**Returns:** `SuperDocZoomState` - `{ mode, value, fitZoom, min, max }`. `fitZoom` is `null` before the first viewport measurement.
+
+
+
+```javascript Usage
+const { mode, value, fitZoom } = superdoc.getZoomState();
+```
+
+```javascript Full Example
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: yourFile,
+ onReady: ({ superdoc }) => {
+ const state = superdoc.getZoomState();
+ console.log(`Mode: ${state.mode}, value: ${state.value}%`);
+ },
+});
+```
+
+
+
+### `getViewportMetrics`
+
+Get the latest fit-width measurements. Use this when you need custom zoom behavior instead of `zoom.mode: 'fit-width'`.
+
+**Returns:** `SuperDocViewportMetrics | null` - `{ availableWidth, documentWidth, fitZoom }`, or `null` until the first measurement (editors still mounting).
+
+
+
+```javascript Usage
+const metrics = superdoc.getViewportMetrics();
+```
+
+```javascript Full Example
+import { SuperDoc } from 'superdoc';
+import 'superdoc/style.css';
+
+const superdoc = new SuperDoc({
+ selector: '#editor',
+ document: yourFile,
+ onReady: ({ superdoc }) => {
+ const metrics = superdoc.getViewportMetrics();
+ if (metrics) {
+ superdoc.setZoom(Math.min(100, metrics.fitZoom));
+ }
+ },
+});
+```
+
+
+
### `focus`
Focus the active editor or first available.
diff --git a/apps/docs/getting-started/frameworks/react.mdx b/apps/docs/getting-started/frameworks/react.mdx
index 594e4805e5..4bb434a797 100644
--- a/apps/docs/getting-started/frameworks/react.mdx
+++ b/apps/docs/getting-started/frameworks/react.mdx
@@ -59,6 +59,23 @@ function App() {
```
+### Responsive zoom
+
+Pass `zoom` with `mode: 'fit-width'` to keep the document fitted to its container as it resizes. SuperDoc observes the container for you; no resize listeners needed. Calling `setZoom()` (or the user picking a percentage in the toolbar) switches back to manual mode.
+
+```jsx
+ console.log(`Zoom: ${zoom}% (${mode})`)}
+/>
+```
+
+For custom behavior, listen to `onViewportChange` instead and apply your own zoom with `getInstance().setZoom()`. See [zoom configuration](/editor/superdoc/configuration#param-zoom).
+
## Handle file uploads
```jsx
diff --git a/packages/react/src/SuperDocEditor.test.tsx b/packages/react/src/SuperDocEditor.test.tsx
index d40ff03b64..94d697d35f 100644
--- a/packages/react/src/SuperDocEditor.test.tsx
+++ b/packages/react/src/SuperDocEditor.test.tsx
@@ -162,6 +162,72 @@ describe('SuperDocEditor', () => {
},
SUPERDOC_READY_TEST_TIMEOUT,
);
+
+ it(
+ 'should call onZoomChange when zoom changes through the core wiring',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const onZoomChange = vi.fn();
+
+ render();
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+
+ const instance = ref.current?.getInstance();
+ expect(instance).toBeTruthy();
+
+ instance?.setZoom(150);
+
+ expect(onZoomChange).toHaveBeenCalledWith({ zoom: 150, mode: 'manual' });
+ expect(instance?.getZoom()).toBe(150);
+ expect(instance?.getZoomState().mode).toBe('manual');
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'should route onZoomChange through the latest callback after rerender',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+ const firstOnZoomChange = vi.fn();
+ const secondOnZoomChange = vi.fn();
+
+ const { rerender } = render();
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+
+ const instance = ref.current?.getInstance();
+ expect(instance).toBeTruthy();
+
+ rerender();
+
+ // Same instance (callback identity changes must not rebuild) and the
+ // fresh callback receives the event.
+ expect(ref.current?.getInstance()).toBe(instance);
+ instance?.setZoom(80);
+
+ expect(firstOnZoomChange).not.toHaveBeenCalled();
+ expect(secondOnZoomChange).toHaveBeenCalledWith({ zoom: 80, mode: 'manual' });
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
+
+ it(
+ 'should apply zoom.initial through config passthrough',
+ async () => {
+ const ref = createRef();
+ const onReady = vi.fn();
+
+ render();
+
+ await waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: SUPERDOC_READY_WAIT_TIMEOUT });
+
+ expect(ref.current?.getInstance()?.getZoom()).toBe(50);
+ },
+ SUPERDOC_READY_TEST_TIMEOUT,
+ );
});
describe('onEditorDestroy', () => {
diff --git a/packages/react/src/SuperDocEditor.tsx b/packages/react/src/SuperDocEditor.tsx
index b022552456..5ebe4c9f1d 100644
--- a/packages/react/src/SuperDocEditor.tsx
+++ b/packages/react/src/SuperDocEditor.tsx
@@ -20,6 +20,8 @@ import type {
SuperDocTransactionEvent,
SuperDocContentErrorEvent,
SuperDocExceptionEvent,
+ SuperDocZoomChangeEvent,
+ SuperDocViewportChangeEvent,
} from './types';
/**
@@ -50,6 +52,8 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef(null);
@@ -211,6 +229,16 @@ function SuperDocEditorInner(props: SuperDocEditorProps, ref: ForwardedRef {
+ if (!destroyed) {
+ callbacksRef.current.onZoomChange?.(event);
+ }
+ },
+ onViewportChange: (event: SuperDocViewportChangeEvent) => {
+ if (!destroyed) {
+ callbacksRef.current.onViewportChange?.(event);
+ }
+ },
};
instance = new SuperDoc(superdocConfig) as SuperDocInstance;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index eba3573a70..d62c9b0933 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -27,4 +27,6 @@ export type {
SuperDocTransactionEvent,
SuperDocContentErrorEvent,
SuperDocExceptionEvent,
+ SuperDocZoomChangeEvent,
+ SuperDocViewportChangeEvent,
} from './types';
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index a0a378cd24..ccf299f802 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -104,6 +104,20 @@ export type SuperDocContentErrorEvent = Parameters>[0];
+
+/**
+ * Event passed to onViewportChange callback. Re-derived from the core
+ * `Config.onViewportChange` parameter so the React wrapper cannot
+ * drift from the core contract.
+ */
+export type SuperDocViewportChangeEvent = Parameters>[0];
+
// =============================================================================
// React Component Types
// =============================================================================
@@ -131,7 +145,9 @@ type ExplicitCallbackProps =
| 'onEditorUpdate'
| 'onTransaction'
| 'onContentError'
- | 'onException';
+ | 'onException'
+ | 'onZoomChange'
+ | 'onViewportChange';
/**
* Explicitly typed callback props to ensure proper TypeScript inference.
@@ -158,6 +174,12 @@ export interface CallbackProps {
/** Callback when an exception is thrown */
onException?: (event: SuperDocExceptionEvent) => void;
+
+ /** Callback when the zoom level changes (setZoom, toolbar, or fit-width mode) */
+ onZoomChange?: (event: SuperDocZoomChangeEvent) => void;
+
+ /** Callback when the implied fit changes (rounded fit zoom or base page width); see the core viewport-change event */
+ onViewportChange?: (event: SuperDocViewportChangeEvent) => void;
}
/**
diff --git a/packages/super-editor/src/headless-toolbar/helpers/document.ts b/packages/super-editor/src/headless-toolbar/helpers/document.ts
index 958f90e669..ff0023a1d0 100644
--- a/packages/super-editor/src/headless-toolbar/helpers/document.ts
+++ b/packages/super-editor/src/headless-toolbar/helpers/document.ts
@@ -144,6 +144,16 @@ export const createZoomStateDeriver =
};
};
+export const createZoomFitWidthStateDeriver =
+ () =>
+ ({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => {
+ const mode = typeof superdoc?.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined;
+ return {
+ active: mode === 'fit-width',
+ disabled: !context || typeof superdoc?.setZoomMode !== 'function',
+ };
+ };
+
export const createDocumentModeStateDeriver =
() =>
({ context, superdoc }: { context: ToolbarContext | null; superdoc: Record }): ToolbarCommandState => {
@@ -182,6 +192,18 @@ export const createZoomExecute =
return true;
};
+// Toggle fit-width mode. A second activation returns to manual at the
+// current value, matching toolbar toggle conventions; numeric zoom stays
+// on the separate `zoom` command.
+export const createZoomFitWidthExecute =
+ () =>
+ ({ superdoc }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => {
+ if (typeof superdoc?.setZoomMode !== 'function') return false;
+ const mode = typeof superdoc.getZoomState === 'function' ? superdoc.getZoomState()?.mode : undefined;
+ superdoc.setZoomMode(mode === 'fit-width' ? 'manual' : 'fit-width');
+ return true;
+ };
+
export const createDocumentModeExecute =
() =>
({ superdoc, payload }: { context: ToolbarContext | null; superdoc: Record; payload?: unknown }) => {
diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts
index 541e886596..dedc6fe3b2 100644
--- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts
+++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts
@@ -986,6 +986,75 @@ describe('createToolbarRegistry', () => {
});
});
+ it('derives zoom-fit-width active state from the host zoom mode', () => {
+ const registry = createToolbarRegistry();
+
+ const fitActive = registry['zoom-fit-width']?.state({
+ context: createContext(),
+ superdoc: {
+ getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })),
+ setZoomMode: vi.fn(),
+ },
+ });
+ expect(fitActive).toEqual({ active: true, disabled: false });
+
+ const manual = registry['zoom-fit-width']?.state({
+ context: createContext(),
+ superdoc: {
+ getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })),
+ setZoomMode: vi.fn(),
+ },
+ });
+ expect(manual).toEqual({ active: false, disabled: false });
+ });
+
+ it('disables zoom-fit-width without a context or a setZoomMode host bridge', () => {
+ const registry = createToolbarRegistry();
+
+ const noContext = registry['zoom-fit-width']?.state({
+ context: null,
+ superdoc: { setZoomMode: vi.fn(), getZoomState: vi.fn(() => ({ mode: 'manual' })) },
+ });
+ expect(noContext?.disabled).toBe(true);
+
+ const noBridge = registry['zoom-fit-width']?.state({
+ context: createContext(),
+ superdoc: {},
+ });
+ expect(noBridge?.disabled).toBe(true);
+ });
+
+ it('zoom-fit-width execute toggles between fit-width and manual', () => {
+ const registry = createToolbarRegistry();
+
+ const setZoomMode = vi.fn();
+ const fromManual = registry['zoom-fit-width']?.execute?.({
+ context: createContext(),
+ superdoc: {
+ setZoomMode,
+ getZoomState: vi.fn(() => ({ mode: 'manual', value: 100, fitZoom: null, min: 10, max: 100 })),
+ },
+ });
+ expect(fromManual).toBe(true);
+ expect(setZoomMode).toHaveBeenCalledWith('fit-width');
+
+ const setZoomModeBack = vi.fn();
+ registry['zoom-fit-width']?.execute?.({
+ context: createContext(),
+ superdoc: {
+ setZoomMode: setZoomModeBack,
+ getZoomState: vi.fn(() => ({ mode: 'fit-width', value: 84, fitZoom: 84, min: 10, max: 100 })),
+ },
+ });
+ expect(setZoomModeBack).toHaveBeenCalledWith('manual');
+
+ const noBridge = registry['zoom-fit-width']?.execute?.({
+ context: createContext(),
+ superdoc: {},
+ });
+ expect(noBridge).toBe(false);
+ });
+
it('enables track-changes accept-selection when selection contains tracked changes and action is allowed', () => {
collectTrackedChangesMock.mockReturnValueOnce([{ id: 'tc-1' }]);
isTrackedChangeActionAllowedMock.mockReturnValueOnce(true);
diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts
index 3ea56a72d4..bd96c6a229 100644
--- a/packages/super-editor/src/headless-toolbar/toolbar-registry.ts
+++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.ts
@@ -8,6 +8,8 @@ import {
createRulerExecute,
createRulerStateDeriver,
createZoomExecute,
+ createZoomFitWidthExecute,
+ createZoomFitWidthStateDeriver,
createZoomStateDeriver,
} from './helpers/document.js';
import {
@@ -187,6 +189,11 @@ export const createToolbarRegistry = (): Partial {
expect(ok).toHaveBeenCalledTimes(2);
});
});
+
+describe('ui.zoom', () => {
+ let teardown: Array<() => void> = [];
+
+ afterEach(() => {
+ teardown.forEach((fn) => fn());
+ teardown = [];
+ });
+
+ const attachZoomSurface = (superdoc: ReturnType) => {
+ const zoomHost = {
+ state: {
+ mode: 'manual' as 'manual' | 'fit-width',
+ value: 100,
+ fitZoom: null as number | null,
+ min: 10,
+ max: 100,
+ },
+ metrics: null as { availableWidth: number; documentWidth: number; fitZoom: number } | null,
+ setZoom: vi.fn(),
+ setZoomMode: vi.fn(),
+ };
+ superdoc.getZoomState = vi.fn(() => ({ ...zoomHost.state }));
+ superdoc.getViewportMetrics = vi.fn(() => zoomHost.metrics);
+ superdoc.setZoom = zoomHost.setZoom;
+ superdoc.setZoomMode = zoomHost.setZoomMode;
+ return zoomHost;
+ };
+
+ it('degrades to a static manual/100 snapshot when the host lacks the zoom surface', () => {
+ const superdoc = makeSuperdocStub();
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ expect(ui.zoom.getSnapshot()).toEqual({
+ mode: 'manual',
+ value: 100,
+ fitZoom: null,
+ min: 10,
+ max: 100,
+ metrics: null,
+ });
+ // Mutations are no-ops, not crashes.
+ expect(() => ui.zoom.set(150)).not.toThrow();
+ expect(() => ui.zoom.setMode('fit-width')).not.toThrow();
+ });
+
+ it('snapshots the host zoom state and metrics', () => {
+ const superdoc = makeSuperdocStub();
+ const zoomHost = attachZoomSurface(superdoc);
+ zoomHost.state = { mode: 'fit-width', value: 84, fitZoom: 84, min: 25, max: 100 };
+ zoomHost.metrics = { availableWidth: 685, documentWidth: 816, fitZoom: 84 };
+
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ expect(ui.zoom.getSnapshot()).toEqual({
+ mode: 'fit-width',
+ value: 84,
+ fitZoom: 84,
+ min: 25,
+ max: 100,
+ metrics: { availableWidth: 685, documentWidth: 816, fitZoom: 84 },
+ });
+ });
+
+ it('observes mode-only transitions via zoomChange', async () => {
+ const superdoc = makeSuperdocStub();
+ const zoomHost = attachZoomSurface(superdoc);
+
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ const cb = vi.fn();
+ teardown.push(ui.zoom.observe(cb));
+ expect(cb).toHaveBeenCalledTimes(1);
+ expect(cb.mock.calls[0][0].mode).toBe('manual');
+
+ zoomHost.state = { ...zoomHost.state, mode: 'fit-width' };
+ superdoc.fireSuperdoc('zoomChange', { zoom: 100, mode: 'fit-width' });
+ await flushMicrotasks();
+
+ expect(cb).toHaveBeenCalledTimes(2);
+ expect(cb.mock.calls[1][0].mode).toBe('fit-width');
+ expect(cb.mock.calls[1][0].value).toBe(100);
+ });
+
+ it('observes viewport metric updates via viewport-change', async () => {
+ const superdoc = makeSuperdocStub();
+ const zoomHost = attachZoomSurface(superdoc);
+
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ const cb = vi.fn();
+ teardown.push(ui.zoom.observe(cb));
+ expect(cb).toHaveBeenCalledTimes(1);
+
+ zoomHost.state = { ...zoomHost.state, fitZoom: 74 };
+ zoomHost.metrics = { availableWidth: 600, documentWidth: 816, fitZoom: 74 };
+ superdoc.fireSuperdoc('viewport-change', zoomHost.metrics);
+ await flushMicrotasks();
+
+ expect(cb).toHaveBeenCalledTimes(2);
+ expect(cb.mock.calls[1][0].fitZoom).toBe(74);
+ expect(cb.mock.calls[1][0].metrics).toEqual({ availableWidth: 600, documentWidth: 816, fitZoom: 74 });
+ });
+
+ it('does not re-fire observers when zoom state is unchanged', async () => {
+ const superdoc = makeSuperdocStub();
+ attachZoomSurface(superdoc);
+
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ const cb = vi.fn();
+ teardown.push(ui.zoom.observe(cb));
+ expect(cb).toHaveBeenCalledTimes(1);
+
+ // Unrelated recompute trigger with identical zoom state.
+ superdoc.fireSuperdoc('document-mode-change', { documentMode: 'viewing' });
+ await flushMicrotasks();
+
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it('routes set and setMode to the host zoom surface', () => {
+ const superdoc = makeSuperdocStub();
+ const zoomHost = attachZoomSurface(superdoc);
+
+ const ui = createSuperDocUI({ superdoc });
+ teardown.push(() => ui.destroy());
+
+ ui.zoom.set(125);
+ expect(zoomHost.setZoom).toHaveBeenCalledWith(125);
+
+ ui.zoom.setMode('fit-width');
+ expect(zoomHost.setZoomMode).toHaveBeenCalledWith('fit-width');
+ });
+});
diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts
index e577285f62..84e46a51b1 100644
--- a/packages/super-editor/src/ui/create-super-doc-ui.ts
+++ b/packages/super-editor/src/ui/create-super-doc-ui.ts
@@ -72,6 +72,9 @@ import type {
ContentControlFocusResult,
ViewportRect,
ViewportRectResult,
+ ZoomHandle,
+ ZoomMode,
+ ZoomSlice,
} from './types.js';
/**
@@ -110,7 +113,7 @@ const EDITOR_EVENTS = [
*/
const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'tracked-changes-changed'] as const;
-const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const;
+const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange', 'viewport-change'] as const;
/**
* Presentation-editor events the controller listens to. These signal
@@ -701,6 +704,64 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
* either field, but they do trigger computeState rebuilds).
*/
let documentMemo: { slice: DocumentSlice } | null = null;
+ let zoomMemo: { slice: ZoomSlice } | null = null;
+
+ // Static fallback for hosts without the zoom surface (older builds,
+ // minimal stubs): manual mode at 100% with no metrics.
+ const FALLBACK_ZOOM_SLICE: ZoomSlice = Object.freeze({
+ mode: 'manual',
+ value: 100,
+ fitZoom: null,
+ min: 10,
+ max: 100,
+ metrics: null,
+ });
+
+ // Read the host zoom state + metrics into one slice. Memoized on the
+ // field values. Metrics compare by reference, which is equivalent to a
+ // field-wise compare because the host's viewport-fit store replaces the
+ // (frozen) metrics object only when a field actually changed; if that
+ // invariant moves, switch this to field-wise. `shallowEqual` on
+ // `state.zoom` then short-circuits `ui.zoom.observe` while nothing
+ // zoom-related changes.
+ const computeZoomSlice = (): ZoomSlice => {
+ if (typeof superdoc.getZoomState !== 'function') return FALLBACK_ZOOM_SLICE;
+ let state: ReturnType> | null = null;
+ try {
+ state = superdoc.getZoomState();
+ } catch {
+ state = null;
+ }
+ if (!state) return FALLBACK_ZOOM_SLICE;
+ let metrics: ZoomSlice['metrics'] = null;
+ try {
+ metrics = superdoc.getViewportMetrics?.() ?? null;
+ } catch {
+ metrics = null;
+ }
+ const prev = zoomMemo?.slice;
+ if (
+ prev &&
+ prev.mode === state.mode &&
+ prev.value === state.value &&
+ prev.fitZoom === state.fitZoom &&
+ prev.min === state.min &&
+ prev.max === state.max &&
+ prev.metrics === metrics
+ ) {
+ return prev;
+ }
+ const slice: ZoomSlice = {
+ mode: state.mode,
+ value: state.value,
+ fitZoom: state.fitZoom,
+ min: state.min,
+ max: state.max,
+ metrics,
+ };
+ zoomMemo = { slice };
+ return slice;
+ };
/**
* Internal dirty flag. Flipped to `true` by any editor transaction
@@ -937,6 +998,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
documentMode,
document: documentSlice,
selection: selectionSlice,
+ zoom: computeZoomSlice(),
toolbar: { context: toolbarSnapshot.context, commands: builtInCommands } as ToolbarSnapshotSlice,
comments: {
total: commentsListCache.total,
@@ -1034,7 +1096,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
};
// zoomChange fires *before* the re-render, so notifying then would hand
// consumers stale rects. Tag the next post-paint layout flush as 'zoom'.
- const onGeometryZoom = () => {
+ // Only a changed VALUE schedules a repaint; mode-only transitions
+ // (setZoomMode with an unchanged value) would latch a tag no flush ever
+ // consumes, mis-labeling the next unrelated layout notification.
+ let lastGeometryZoomValue: number | null = (() => {
+ try {
+ return superdoc.getZoomState?.().value ?? null;
+ } catch {
+ return null;
+ }
+ })();
+ const onGeometryZoom = (...args: unknown[]) => {
+ const payload = args[0] as { zoom?: number } | undefined;
+ const nextZoom = typeof payload?.zoom === 'number' ? payload.zoom : null;
+ if (nextZoom !== null && nextZoom === lastGeometryZoomValue) return;
+ lastGeometryZoomValue = nextZoom;
zoomPending = true;
};
const onGeometryLayout = () => {
@@ -2328,6 +2404,42 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
},
};
+ // ---- ui.zoom -----------------------------------------------------------
+ // One slice for zoom UIs (mode, value, fit zoom, bounds, metrics) plus
+ // the two mutations. Recomputes on the host's zoomChange (which now
+ // includes mode-only transitions) and viewport-change events; both are
+ // in SUPERDOC_EVENTS.
+ const zoom: ZoomHandle = {
+ getSnapshot: () => computeState().zoom,
+ observe(listener) {
+ return select((state) => state.zoom, shallowEqual).subscribe((snapshot) => {
+ try {
+ listener(snapshot);
+ } catch {
+ // see scheduleNotify
+ }
+ });
+ },
+ set(percent: number) {
+ const setter = superdoc.setZoom;
+ if (typeof setter !== 'function') return;
+ try {
+ setter.call(superdoc, percent);
+ } catch (err) {
+ console.error('[superdoc/ui] ui.zoom.set failed:', err);
+ }
+ },
+ setMode(mode: ZoomMode) {
+ const setter = superdoc.setZoomMode;
+ if (typeof setter !== 'function') return;
+ try {
+ setter.call(superdoc, mode);
+ } catch (err) {
+ console.error('[superdoc/ui] ui.zoom.setMode failed:', err);
+ }
+ },
+ };
+
// Live scopes created via `ui.createScope()`. The controller's
// `destroy()` cascades into every entry before tearing down its own
// resources, so consumers do not need to call `scope.destroy()`
@@ -2597,6 +2709,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
selection,
viewport,
document,
+ zoom,
createScope: createScopeFn,
destroy,
};
diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts
index 68e778013e..e3594e6773 100644
--- a/packages/super-editor/src/ui/index.ts
+++ b/packages/super-editor/src/ui/index.ts
@@ -148,4 +148,8 @@ export type {
DocumentExportInput,
DocumentHandle,
DocumentSlice,
+ ZoomHandle,
+ ZoomMode,
+ ZoomSlice,
+ ZoomViewportMetrics,
} from './types.js';
diff --git a/packages/super-editor/src/ui/react/hooks.ts b/packages/super-editor/src/ui/react/hooks.ts
index 7beba5ca28..aa92e0d9fd 100644
--- a/packages/super-editor/src/ui/react/hooks.ts
+++ b/packages/super-editor/src/ui/react/hooks.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { shallowEqual } from '../equality.js';
import type {
CommentsSlice,
@@ -8,6 +8,8 @@ import type {
SelectionSlice,
ToolbarSnapshotSlice,
UIToolbarCommandState,
+ ZoomMode,
+ ZoomSlice,
} from '../types.js';
import { useSuperDocSlice, useSuperDocUI } from './provider.js';
@@ -87,6 +89,61 @@ export function useSuperDocDocument(): DocumentSlice {
return useSuperDocSlice((ui) => ui.select((state) => state.document, shallowEqual), EMPTY_DOCUMENT);
}
+const EMPTY_ZOOM: ZoomSlice = {
+ mode: 'manual',
+ value: 100,
+ fitZoom: null,
+ min: 10,
+ max: 100,
+ metrics: null,
+};
+
+/**
+ * Zoom state + actions for custom zoom UIs. The snapshot updates on
+ * value changes, mode-only transitions, and viewport metric updates;
+ * `set(percent)` switches the host to manual mode by contract,
+ * `setMode('fit-width')` re-enters automatic fitting.
+ *
+ * ```tsx
+ * const zoom = useSuperDocZoom();
+ * return (
+ * <>
+ * {zoom.value}%
+ *
+ * >
+ * );
+ * ```
+ */
+export function useSuperDocZoom(): ZoomSlice & {
+ set: (percent: number) => void;
+ setMode: (mode: ZoomMode) => void;
+} {
+ const ui = useSuperDocUI();
+ const slice = useSuperDocSlice((controller) => controller.select((state) => state.zoom, shallowEqual), EMPTY_ZOOM);
+ const set = useCallback(
+ (percent: number) => {
+ ui?.zoom.set(percent);
+ },
+ [ui],
+ );
+ const setMode = useCallback(
+ (mode: ZoomMode) => {
+ ui?.zoom.setMode(mode);
+ },
+ [ui],
+ );
+ // Memoized so the returned object keeps its identity while the slice and
+ // actions are unchanged; the controller-side slice memo makes `slice`
+ // reference-stable, and effects keyed on this hook's result must not
+ // re-run on unrelated parent renders.
+ return useMemo(() => ({ ...slice, set, setMode }), [slice, set, setMode]);
+}
+
const FALLBACK_COMMAND_STATE: UIToolbarCommandState = {
active: false,
disabled: true,
diff --git a/packages/super-editor/src/ui/react/index.ts b/packages/super-editor/src/ui/react/index.ts
index a285bb6c15..3f0ea99465 100644
--- a/packages/super-editor/src/ui/react/index.ts
+++ b/packages/super-editor/src/ui/react/index.ts
@@ -38,4 +38,5 @@ export {
useSuperDocToolbar,
useSuperDocCommand,
useSuperDocDocument,
+ useSuperDocZoom,
} from './hooks.js';
diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts
index 2169b66ef9..16e7077842 100644
--- a/packages/super-editor/src/ui/types.ts
+++ b/packages/super-editor/src/ui/types.ts
@@ -36,12 +36,12 @@ export interface Subscribable {
/**
* Event names the UI controller (`createSuperDocUI`) subscribes to on
- * a SuperDoc-like host. Narrower than
- * `HeadlessToolbarSuperdocHostEvent` (which adds
- * `formatting-marks-change`); a custom UI host stub only has to
- * support the three events the UI controller actually consumes.
+ * a SuperDoc-like host. Differs from `HeadlessToolbarSuperdocHostEvent`
+ * (which adds `formatting-marks-change` but not `viewport-change`); a
+ * custom UI host stub only has to support the four events the UI
+ * controller actually consumes.
*/
-export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange';
+export type SuperDocUIHostEvent = 'editorCreate' | 'document-mode-change' | 'zoomChange' | 'viewport-change';
/**
* Structural typing for the SuperDoc instance. Keeps the UI controller
@@ -82,6 +82,42 @@ export interface SuperDocLike {
* browser test stubs stay valid without a host implementation.
*/
export?(options?: DocumentExportInput): Promise;
+ /**
+ * Optional zoom bridge consumed by `ui.zoom`. Mirrors the superdoc
+ * package's zoom surface (`setZoom` switches the mode to manual,
+ * `setZoomMode` toggles fitting, the getters snapshot state and
+ * viewport metrics). All optional so stubs and older hosts stay
+ * valid; `ui.zoom` degrades to a static manual/100 snapshot.
+ */
+ setZoom?(percent: number): unknown;
+ setZoomMode?(mode: ZoomMode): unknown;
+ getZoomState?(): {
+ mode: ZoomMode;
+ value: number;
+ fitZoom: number | null;
+ min: number;
+ max: number;
+ };
+ getViewportMetrics?(): ZoomViewportMetrics | null;
+}
+
+/**
+ * Zoom mode mirrored from the superdoc package: `manual` holds the
+ * last-set value, `fit-width` continuously re-fits to the container.
+ */
+export type ZoomMode = 'manual' | 'fit-width';
+
+/**
+ * Pure viewport measurements mirrored from the superdoc package's
+ * `viewport-change` payload / `getViewportMetrics()`.
+ */
+export interface ZoomViewportMetrics {
+ /** Width available to the document in pixels (container minus the comments sidebar). */
+ availableWidth: number;
+ /** Widest document page width in pixels at 100% zoom. */
+ documentWidth: number;
+ /** Unclamped zoom percentage that fits the document in the available width. */
+ fitZoom: number;
}
export interface SuperDocEditorLike {
@@ -309,6 +345,33 @@ export interface SuperDocUIState {
* document transactions; `activeIds` derives from the selection.
*/
contentControls: ContentControlsSlice;
+ /**
+ * Zoom slice. Sourced from the host's `getZoomState()` /
+ * `getViewportMetrics()` and recomputed on `zoomChange` /
+ * `viewport-change`. Hosts without the zoom surface yield a static
+ * manual/100 snapshot.
+ */
+ zoom: ZoomSlice;
+}
+
+/**
+ * Zoom snapshot exposed on `state.zoom` and through `ui.zoom`. Combines
+ * the host's zoom state (mode, value, fit bounds) with the latest
+ * viewport metrics so a zoom UI renders from one slice.
+ */
+export interface ZoomSlice {
+ /** Current zoom mode. */
+ mode: ZoomMode;
+ /** Current zoom value as a percentage. */
+ value: number;
+ /** Latest unclamped fit zoom, or `null` before the first viewport measurement. */
+ fitZoom: number | null;
+ /** Effective lower bound of the fit-width policy. */
+ min: number;
+ /** Effective upper bound of the fit-width policy. */
+ max: number;
+ /** Latest viewport measurements, or `null` before editors mount. */
+ metrics: ZoomViewportMetrics | null;
}
/**
@@ -811,6 +874,17 @@ export interface SuperDocUI {
*/
document: DocumentHandle;
+ /**
+ * Zoom domain. One slice for zoom UIs (mode, value, fit zoom,
+ * bounds, viewport metrics) plus the two mutations: `set(percent)`
+ * (numeric zoom, switches the host to manual mode) and
+ * `setMode('fit-width' | 'manual')`. Sugar over `state.zoom` and
+ * passthroughs to the host's `setZoom` / `setZoomMode`; the slice
+ * recomputes on the host's `zoomChange` and `viewport-change`
+ * events, including mode-only transitions.
+ */
+ zoom: ZoomHandle;
+
/**
* Create a {@link SuperDocUIScope} for collecting subscriptions,
* custom-command registrations, and DOM listeners under one
@@ -1048,6 +1122,36 @@ export interface DocumentHandle {
replaceFile(file: File): Promise;
}
+/**
+ * Zoom domain handle (`ui.zoom`). Read / observe the {@link ZoomSlice}
+ * and mutate through the host's zoom surface. Hosts without zoom
+ * methods (older builds, minimal stubs) degrade gracefully: the slice
+ * is a static manual/100 snapshot and the mutations are no-ops.
+ */
+export interface ZoomHandle {
+ /** Current zoom snapshot. */
+ getSnapshot(): ZoomSlice;
+ /**
+ * Subscribe to zoom snapshots. Fires on value changes, mode-only
+ * transitions, and fit-relevant viewport metric updates (the host's
+ * deduped `viewport-change`); `getSnapshot()` always reads the
+ * latest stored metrics. Returns the unsubscribe function; pair
+ * with `scope.add(...)` for lifecycle handling.
+ */
+ observe(listener: (snapshot: ZoomSlice) => void): () => void;
+ /**
+ * Set a numeric zoom percentage. Routes through `superdoc.setZoom`,
+ * which switches the mode to `manual` by contract.
+ */
+ set(percent: number): void;
+ /**
+ * Switch the zoom mode. Routes through `superdoc.setZoomMode`;
+ * `'fit-width'` applies the fit immediately when viewport metrics
+ * are available.
+ */
+ setMode(mode: ZoomMode): void;
+}
+
/**
* Selection domain handle exposed on `ui.selection`. Same shape as
* `CommentsHandle` / `TrackChangesHandle`: snapshot + subscription. Mirrors
diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js
index 700a1aa1c2..dd72c75a1d 100644
--- a/packages/superdoc/src/SuperDoc.test.js
+++ b/packages/superdoc/src/SuperDoc.test.js
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { h, defineComponent, ref, shallowRef, reactive, nextTick } from 'vue';
-import { DOCX } from '@superdoc/common';
+import { DOCX, PDF } from '@superdoc/common';
import { Schema } from 'prosemirror-model';
import { EditorState, TextSelection } from 'prosemirror-state';
import { Mapping, StepMap } from 'prosemirror-transform';
@@ -104,6 +104,15 @@ const CommentsLayerStub = stubComponent('CommentsLayer');
const HrbrFieldsLayerStub = stubComponent('HrbrFieldsLayer');
const AiLayerStub = stubComponent('AiLayer');
const HtmlViewerStub = stubComponent('HtmlViewer');
+const PdfViewerStub = defineComponent({
+ name: 'PdfViewer',
+ props: ['file', 'fileId', 'config', 'initialScale'],
+ emits: ['page-rendered', 'document-ready', 'selection-raw', 'bypass-selection'],
+ setup(_props, { expose }) {
+ expose({ updateScale: vi.fn() });
+ return () => h('div', { class: 'sd-pdf-viewer' });
+ },
+});
const createTrackedChangeIndexStub = () => ({
subscribe: vi.fn(() => () => {}),
@@ -145,6 +154,16 @@ vi.mock('./components/HtmlViewer/HtmlViewer.vue', () => ({
default: HtmlViewerStub,
}));
+vi.mock('./components/PdfViewer/PdfViewer.vue', () => ({
+ // SuperDoc.vue loads PdfViewer through defineAsyncComponent, so Vue
+ // receives this module namespace and interop-probes it (__isTeleport
+ // etc.); vitest's strict mock proxy throws on undeclared exports. The
+ // __esModule flag makes Vue's resolver take `default` immediately,
+ // before any probing.
+ __esModule: true,
+ default: PdfViewerStub,
+}));
+
vi.mock('@superdoc/components/CommentsLayer/CommentDialog.vue', () => ({
default: CommentDialogStub,
}));
@@ -189,6 +208,8 @@ const buildSuperdocStore = () => {
selectionPosition: ref(null),
activeSelection: ref(null),
activeZoom: ref(100),
+ zoomMode: ref('manual'),
+ viewportMetrics: ref(null),
modules: reactive({ comments: { readOnly: false }, ai: {}, 'hrbr-fields': [] }),
handlePageReady: vi.fn(),
user: { name: 'Ada', email: 'ada@example.com' },
@@ -338,6 +359,7 @@ const mountComponent = async (
const createSuperdocStub = () => {
const toolbar = { config: { aiApiKey: 'abc' }, setActiveEditor: vi.fn(), updateToolbarState: vi.fn() };
const runtimeMap = new Map();
+ const eventHandlers = new Map();
return {
config: {
modules: { comments: {}, ai: {}, toolbar: {}, pdf: {} },
@@ -365,8 +387,24 @@ const createSuperdocStub = () => {
getActiveRuntime: vi.fn(() => null),
activateRuntimeFromEventTarget: vi.fn(() => false),
lockSuperdoc: vi.fn(),
- emit: vi.fn(),
- listeners: vi.fn(),
+ on: vi.fn((eventName, handler) => {
+ const handlers = eventHandlers.get(eventName) ?? new Set();
+ handlers.add(handler);
+ eventHandlers.set(eventName, handlers);
+ return undefined;
+ }),
+ off: vi.fn((eventName, handler) => {
+ eventHandlers.get(eventName)?.delete(handler);
+ return undefined;
+ }),
+ emit: vi.fn((eventName, payload) => {
+ const handlers = eventHandlers.get(eventName);
+ if (handlers) {
+ for (const handler of [...handlers]) handler(payload);
+ }
+ return true;
+ }),
+ listeners: vi.fn((eventName) => [...(eventHandlers.get(eventName) ?? [])]),
captureLayoutPipelineEvent: vi.fn(),
canPerformPermission: vi.fn(() => true),
};
@@ -2914,4 +2952,462 @@ describe('SuperDoc.vue', () => {
const styleVars = wrapper.vm.superdocStyleVars;
expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88');
});
+
+ describe('viewport-change + fit-to-container', () => {
+ // Letter page: 8.5in * 96 = 816px base width through the page-styles path.
+ const stubPageStylesEditor = (superdocStub) => {
+ superdocStub.activeEditor = {
+ getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })),
+ };
+ };
+
+ const setContainerWidth = (wrapper, width) => {
+ const rootEl = wrapper.find('.superdoc').element;
+ const parentEl = rootEl.parentElement;
+ Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: width });
+ if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: width });
+ };
+
+ const viewportChangeCalls = (superdocStub) =>
+ superdocStub.emit.mock.calls.filter(([name]) => name === 'viewport-change');
+
+ it('does not emit viewport-change before isReady', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.isReady.value = false;
+ await nextTick();
+
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ await nextTick();
+
+ expect(viewportChangeCalls(superdocStub).length).toBe(0);
+ });
+
+ it('emits viewport-change with page-styles-derived widths when ready', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1]).toEqual({
+ availableWidth: 1200,
+ documentWidth: 816,
+ fitZoom: 147,
+ });
+ });
+
+ it('keeps documentWidth zoom-independent (page styles win over scaled DOM)', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ // A zoom applied before the first measurement must not corrupt the base.
+ superdocStoreStub.activeZoom.value = 50;
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1].documentWidth).toBe(816);
+ expect(calls[0][1].fitZoom).toBe(147);
+ });
+
+ it('falls back to page styles when laid-out pages are unavailable', async () => {
+ const superdocStub = createSuperdocStub();
+ superdocStub.activeEditor = {
+ getPages: vi.fn(() => {
+ throw new Error('layout not ready');
+ }),
+ getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })),
+ };
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1]).toEqual({
+ availableWidth: 1200,
+ documentWidth: 816,
+ fitZoom: 147,
+ });
+ });
+
+ it('dedupes width changes that round to the same fit', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ // 1199 / 816 rounds to the same fitZoom (147) as 1200 / 816.
+ setContainerWidth(wrapper, 1199);
+ wrapper.vm.recalculateCompactCommentsMode();
+ await nextTick();
+ await nextTick();
+
+ expect(viewportChangeCalls(superdocStub).length).toBe(1);
+ // The event dedupes, but stored metrics stay latest: reads must see
+ // the 1199 measurement the deduped event skipped.
+ expect(superdocStoreStub.viewportMetrics.value.availableWidth).toBe(1199);
+
+ // A materially different width emits again.
+ setContainerWidth(wrapper, 600);
+ wrapper.vm.recalculateCompactCommentsMode();
+ await nextTick();
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(2);
+ expect(calls[1][1].fitZoom).toBe(74);
+ });
+
+ it('skips emission entirely while no document width is measurable', async () => {
+ const superdocStub = createSuperdocStub();
+ // No activeEditor and no laid-out DOM: base width unresolvable.
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(viewportChangeCalls(superdocStub).length).toBe(0);
+ });
+
+ it('does not scan PDF page DOM for DOCX-only documents', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ const rootEl = wrapper.find('.superdoc').element;
+ const querySelectorAllSpy = vi.spyOn(rootEl, 'querySelectorAll');
+
+ const pageEl = document.createElement('div');
+ pageEl.className = 'sd-pdf-viewer-page';
+ rootEl.appendChild(pageEl);
+
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(querySelectorAllSpy).not.toHaveBeenCalledWith('.sd-pdf-viewer-page');
+ expect(viewportChangeCalls(superdocStub)[0][1].documentWidth).toBe(816);
+ });
+
+ const zoomChangeCalls = (superdocStub) => superdocStub.emit.mock.calls.filter(([name]) => name === 'zoomChange');
+
+ it('stores the latest metrics for getViewportMetrics()', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(superdocStoreStub.viewportMetrics.value).toEqual({
+ availableWidth: 1200,
+ documentWidth: 816,
+ fitZoom: 147,
+ });
+ });
+
+ it('fit-width mode applies the fit with default clamping (max 100)', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = { mode: 'fit-width' };
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.zoomMode.value = 'fit-width';
+ setContainerWidth(wrapper, 600);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ // fitZoom = 600 / 816 = 74; within [10, 100] so applied as-is. The
+ // fit writes the store directly (setZoom would flip mode to manual).
+ expect(superdocStoreStub.activeZoom.value).toBe(74);
+ expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]);
+ expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(74);
+ });
+
+ it('clamps the applied fit but emits the raw fitZoom', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { min: 80 } };
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.zoomMode.value = 'fit-width';
+ setContainerWidth(wrapper, 408);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ // Raw fit is 50 (408 / 816); applied value clamps to min 80.
+ expect(viewportChangeCalls(superdocStub)[0][1].fitZoom).toBe(50);
+ expect(superdocStoreStub.activeZoom.value).toBe(80);
+ });
+
+ it('padding shapes the applied fit only, never the metrics', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = { mode: 'fit-width', fitWidth: { padding: 96 } };
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.zoomMode.value = 'fit-width';
+ superdocStoreStub.activeZoom.value = 50;
+ setContainerWidth(wrapper, 912);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ // Metrics are policy-free: availableWidth stays 912 and fitZoom is
+ // the raw ratio. The applied fit reserves the padding: (912 - 96) /
+ // 816 = 100.
+ expect(viewportChangeCalls(superdocStub)[0][1]).toEqual({
+ availableWidth: 912,
+ documentWidth: 816,
+ fitZoom: 112,
+ });
+ expect(superdocStoreStub.activeZoom.value).toBe(100);
+ });
+
+ it('subtracts the comments sidebar width through the owned template ref', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+
+ const wrapper = await mountComponent(superdocStub);
+ commentsStoreStub.pendingComment.value = { commentId: 'pending-1', selection: { selectionBounds: {} } };
+ await nextTick();
+
+ const sidebarEl = wrapper.find('.superdoc__right-sidebar').element;
+ Object.defineProperty(sidebarEl, 'offsetWidth', { configurable: true, value: 240 });
+
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1]).toEqual({
+ availableWidth: 960,
+ documentWidth: 816,
+ fitZoom: 118,
+ });
+ });
+
+ it('does not re-apply zoom when the target equals the current zoom', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = { mode: 'fit-width' };
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.zoomMode.value = 'fit-width';
+ superdocStoreStub.activeZoom.value = 74;
+ setContainerWidth(wrapper, 600);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ // Same-value guard: target 74 equals current zoom, so no write and
+ // no zoomChange, while the viewport-change event still emits.
+ expect(superdocStoreStub.activeZoom.value).toBe(74);
+ expect(zoomChangeCalls(superdocStub).length).toBe(0);
+ expect(viewportChangeCalls(superdocStub).length).toBe(1);
+ });
+
+ it('manual mode never applies the fit (metrics still emit)', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = { fitWidth: { min: 25 } };
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 600);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(viewportChangeCalls(superdocStub).length).toBe(1);
+ expect(superdocStoreStub.activeZoom.value).toBe(100);
+ expect(zoomChangeCalls(superdocStub).length).toBe(0);
+ });
+
+ it('switching zoomMode to fit-width applies the fit immediately', async () => {
+ const superdocStub = createSuperdocStub();
+ stubPageStylesEditor(superdocStub);
+ superdocStub.config.zoom = {};
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 600);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(superdocStoreStub.activeZoom.value).toBe(100);
+
+ superdocStoreStub.zoomMode.value = 'fit-width';
+ await nextTick();
+
+ expect(superdocStoreStub.activeZoom.value).toBe(74);
+ expect(zoomChangeCalls(superdocStub)).toEqual([['zoomChange', { zoom: 74, mode: 'fit-width' }]]);
+ });
+
+ it('resolves documentWidth as the widest page across documents', async () => {
+ const superdocStub = createSuperdocStub();
+
+ const wrapper = await mountComponent(superdocStub);
+ // Two DOCX documents: portrait letter (8.5in) and landscape (11in).
+ // Zoom is global, so the fit must target the widest page.
+ superdocStoreStub.documents.value = [
+ {
+ id: 'doc-portrait',
+ type: DOCX,
+ editorMountNonce: ref(0),
+ getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 8.5, height: 11 } }) })),
+ setEditor: vi.fn(),
+ },
+ {
+ id: 'doc-landscape',
+ type: DOCX,
+ editorMountNonce: ref(0),
+ getEditor: vi.fn(() => ({ getPageStyles: () => ({ pageSize: { width: 11, height: 8.5 } }) })),
+ setEditor: vi.fn(),
+ },
+ ];
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ // 11in * 96 = 1056: the widest page wins over 816.
+ expect(calls[0][1].documentWidth).toBe(1056);
+ expect(calls[0][1].fitZoom).toBe(114);
+ });
+
+ it('prefers the widest laid-out page over body page styles (landscape sections)', async () => {
+ const superdocStub = createSuperdocStub();
+ // Portrait body section (8.5in) but a laid-out interior landscape
+ // page: the fit must target what the renderer paints (getPages max),
+ // exactly like SuperEditor's own container sizing.
+ superdocStub.activeEditor = {
+ getPages: vi.fn(() => [{ size: { w: 816 } }, { size: { w: 1056 } }]),
+ getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })),
+ };
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1].documentWidth).toBe(1056);
+ expect(calls[0][1].fitZoom).toBe(114);
+ });
+
+ it('re-evaluates document width after pagination updates', async () => {
+ const superdocStub = createSuperdocStub();
+ let pages = [{ size: { w: 816 } }];
+ superdocStub.activeEditor = {
+ getPages: vi.fn(() => pages),
+ getPageStyles: vi.fn(() => ({ pageSize: { width: 8.5, height: 11 } })),
+ };
+
+ const wrapper = await mountComponent(superdocStub);
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ expect(viewportChangeCalls(superdocStub)[0][1].documentWidth).toBe(816);
+
+ pages = [{ size: { w: 816 } }, { size: { w: 1056 } }];
+ superdocStub.emit.mockClear();
+ superdocStub.emit('pagination-update', { totalPages: 2, superdoc: superdocStub });
+
+ expect(viewportChangeCalls(superdocStub).length).toBe(0);
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1]).toEqual({
+ availableWidth: 1200,
+ documentWidth: 1056,
+ fitZoom: 114,
+ });
+ });
+
+ it('resolves PDF page width scale-relatively (zoom-sync state cannot corrupt the base)', async () => {
+ const superdocStub = createSuperdocStub();
+
+ const wrapper = await mountComponent(superdocStub);
+ superdocStoreStub.documents.value = [
+ {
+ id: 'pdf-1',
+ type: PDF,
+ data: { name: 'doc.pdf' },
+ editorMountNonce: ref(0),
+ setEditor: vi.fn(),
+ getEditor: vi.fn(() => null),
+ },
+ ];
+ await nextTick();
+
+ // A rendered 612pt PDF page at 50% viewer zoom measures 408px with
+ // --scale-factor 2/3 (zoom * 96/72). The store zoom claims 100% (a
+ // seeded zoom the viewer has not applied yet); the resolver must
+ // trust the page's actual scale factor and report 816 regardless.
+ const pageEl = document.createElement('div');
+ pageEl.className = 'sd-pdf-viewer-page';
+ Object.defineProperty(pageEl, 'clientWidth', { configurable: true, value: 408 });
+ wrapper.find('.superdoc').element.appendChild(pageEl);
+
+ const originalGetComputedStyle = window.getComputedStyle.bind(window);
+ const computedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => {
+ if (el === pageEl) {
+ return { getPropertyValue: (prop) => (prop === '--scale-factor' ? `${2 / 3}` : '') };
+ }
+ return originalGetComputedStyle(el, pseudo);
+ });
+
+ try {
+ setContainerWidth(wrapper, 1200);
+ wrapper.vm.recalculateCompactCommentsMode();
+ superdocStoreStub.isReady.value = true;
+ await nextTick();
+
+ const calls = viewportChangeCalls(superdocStub);
+ expect(calls.length).toBe(1);
+ expect(calls[0][1].documentWidth).toBeCloseTo(816, 6);
+ expect(calls[0][1].fitZoom).toBe(147);
+ } finally {
+ computedStyleSpy.mockRestore();
+ }
+ });
+ });
});
diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue
index 0328040cf2..33262f0dbe 100644
--- a/packages/superdoc/src/SuperDoc.vue
+++ b/packages/superdoc/src/SuperDoc.vue
@@ -50,6 +50,7 @@ import { useAi } from './composables/use-ai';
import { useHighContrastMode } from './composables/use-high-contrast-mode';
import { useCommentSmallScreen } from './composables/use-comment-small-screen.js';
import { useCompactCommentPopover } from './composables/use-compact-comment-popover.js';
+import { useViewportFit } from './composables/use-viewport-fit.js';
import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js';
import { useUiFontFamily } from './composables/useUiFontFamily.js';
import { usePasswordPrompt } from './composables/use-password-prompt.js';
@@ -81,6 +82,8 @@ const {
selectionPosition,
activeSelection,
activeZoom,
+ zoomMode,
+ viewportMetrics,
} = storeToRefs(superdocStore);
const { handlePageReady, modules, user, getDocument } = superdocStore;
@@ -221,6 +224,7 @@ const superdocStyleVars = computed(() => {
// Refs
const superdocRoot = ref(null);
const layers = ref(null);
+const rightSidebarRef = ref(null);
const pdfViewerRef = ref(null);
const pendingReplayTrackedChangeSync = ref(false);
const toolsMenuPosition = reactive({ top: null, right: '-25px', zIndex: 101 });
@@ -349,6 +353,7 @@ const handleDocumentReady = (documentId, container) => {
if (!proxy.$superdoc.config.collaboration) isReady.value = true;
}
+ ensureInitialFallbackZoom();
isFloatingCommentsReady.value = true;
hasInitializedLocations.value = true;
proxy.$superdoc.broadcastPdfDocumentReady();
@@ -495,6 +500,9 @@ const onEditorCreate = ({ editor }) => {
* @param {PresentationEditor} payload.presentationEditor - The PresentationEditor wrapper
*/
const onEditorReady = ({ editor, presentationEditor }) => {
+ // Legacy (non-layout-engine) editors return early below; the seeded
+ // initial zoom for their CSS-fallback transform must apply first.
+ ensureInitialFallbackZoom();
if (!presentationEditor) return;
// Store presentationEditor reference for mode changes
@@ -1432,6 +1440,21 @@ watch(showCommentsSidebar, (value) => {
proxy.$superdoc.broadcastSidebarToggle(value);
});
+// Viewport fit tracking: maintains viewport metrics, emits `viewport-change`,
+// and applies the fit-width zoom policy. See composables/use-viewport-fit.js.
+useViewportFit({
+ getSuperdoc: () => proxy.$superdoc,
+ superdocContainerWidth,
+ isReady,
+ activeZoom,
+ zoomMode,
+ viewportMetrics,
+ showCommentsSidebar,
+ rightSidebarRef,
+ superdocRoot,
+ documents,
+});
+
/**
* Scroll the page to a given commentId
*
@@ -1760,6 +1783,43 @@ const handlePdfSelectionRaw = ({ selectionBounds, documentId, page }) => {
handleSelectionChange(selection);
};
+// Web layout without layout engine - apply CSS transform directly
+// to non-PDF sub-document containers so zoom works for PM fallback rendering.
+// PDF documents are excluded because pdfViewer.updateScale() handles their zoom
+// separately; applying both would result in double-zoom.
+const applyFallbackZoomStyles = (zoomFactor) => {
+ const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document');
+ subDocs?.forEach((el) => {
+ if (el.querySelector('.sd-pdf-viewer')) return;
+ if (zoomFactor === 1) {
+ el.style.transformOrigin = '';
+ el.style.transform = '';
+ el.style.width = '';
+ } else {
+ el.style.transformOrigin = 'top left';
+ el.style.transform = `scale(${zoomFactor})`;
+ el.style.width = `${100 / zoomFactor}%`;
+ }
+ });
+};
+
+// One-time initial application for surfaces that only consume zoom
+// imperatively. A seeded `zoom.initial` never fires the activeZoom watcher
+// (the ref starts at the seeded value), and the fallback transform targets
+// elements that do not exist until documents render - so apply once from
+// the per-document ready hooks. PresentationEditor and PdfViewer take
+// their initial value at creation (layoutEngineOptions.zoom /
+// :initial-scale) and need nothing here.
+let initialFallbackZoomApplied = false;
+const ensureInitialFallbackZoom = () => {
+ if (initialFallbackZoomApplied) return;
+ if (proxy.$superdoc.config.useLayoutEngine !== false) return;
+ const zoomFactor = (activeZoom.value ?? 100) / 100;
+ if (zoomFactor === 1) return;
+ initialFallbackZoomApplied = true;
+ nextTick(() => applyFallbackZoomStyles(zoomFactor));
+};
+
watch(
() => activeZoom.value,
(zoom) => {
@@ -1768,23 +1828,8 @@ watch(
if (proxy.$superdoc.config.useLayoutEngine !== false) {
PresentationEditor.setGlobalZoom(zoomFactor);
} else {
- // Web layout without layout engine — apply CSS transform directly
- // to non-PDF sub-document containers so zoom works for PM fallback rendering.
- // PDF documents are excluded because pdfViewer.updateScale() handles their zoom
- // separately below; applying both would result in double-zoom.
- const subDocs = layers.value?.querySelectorAll('.superdoc__sub-document');
- subDocs?.forEach((el) => {
- if (el.querySelector('.sd-pdf-viewer')) return;
- if (zoomFactor === 1) {
- el.style.transformOrigin = '';
- el.style.transform = '';
- el.style.width = '';
- } else {
- el.style.transformOrigin = 'top left';
- el.style.transform = `scale(${zoomFactor})`;
- el.style.width = `${100 / zoomFactor}%`;
- }
- });
+ initialFallbackZoomApplied = true;
+ applyFallbackZoomStyles(zoomFactor);
}
const pdfViewer = getPDFViewer();
@@ -1926,6 +1971,7 @@ const getPDFViewer = () => {
v-if="doc.type === PDF"
:file="doc.data"
:file-id="doc.id"
+ :initial-scale="(activeZoom ?? 100) / 100"
:config="pdfConfig"
@selection-raw="handlePdfSelectionRaw"
@bypass-selection="handlePdfClick"
@@ -1956,7 +2002,7 @@ const getPDFViewer = () => {
-