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 = () => { -