From 7bad8f058f1d470106a127c97c8d7f46831e2ce2 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 14:14:11 -0400 Subject: [PATCH 1/3] feat(superdoc): add layout-change event for responsive fit-to-container zoom Adds a new `layout-change` event that fires when container dimensions change, enabling customers to implement responsive fit-to-container zoom without manual polling or ResizeObservers. Payload includes containerWidth, documentWidth, and fitZoom (calculated zoom to fit document in container). Base document width is captured once at 100% zoom to avoid feedback loops when setZoom is called. Closes SD-3294 Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.vue | 33 +++++++++++++++++++ packages/superdoc/src/core/SuperDoc.ts | 2 ++ packages/superdoc/src/core/types/index.ts | 13 ++++++++ .../src/dev/components/SuperdocDev.vue | 12 +++++++ 4 files changed, 60 insertions(+) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index f70bcaf6cb..3e1eab610c 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -57,6 +57,7 @@ import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracke import SurfaceHost from './components/surfaces/SurfaceHost.vue'; import { DEFAULT_COMMENTS_DISPLAY_MODE, + DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX, RIGHT_CLICK_COMMENT_SUPPRESS_MS, VALID_COMMENTS_DISPLAY_MODES, } from './helpers/comment-small-screen.js'; @@ -1321,6 +1322,38 @@ watch(showCommentsSidebar, (value) => { proxy.$superdoc.broadcastSidebarToggle(value); }); +// Emit layout-change event when container width changes. +// Capture base document width once at 100% zoom to avoid feedback loops. +let baseDocumentWidth = null; +let lastEmittedFitZoom = null; + +const emitLayoutChange = () => { + const containerWidth = superdocContainerWidth.value; + if (!proxy.$superdoc || containerWidth <= 0) return; + + // Capture base width once on first call (document at 100% zoom) + if (baseDocumentWidth === null) { + const docEl = superdocRoot.value?.querySelector('.superdoc__document'); + const measured = docEl?.clientWidth || docEl?.getBoundingClientRect?.().width || 0; + baseDocumentWidth = measured > 0 ? measured : DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX; + } + + const rawFitZoom = (containerWidth / baseDocumentWidth) * 100; + const fitZoom = Math.round(rawFitZoom); + + // Only emit if fitZoom changed + if (fitZoom === lastEmittedFitZoom) return; + lastEmittedFitZoom = fitZoom; + + proxy.$superdoc.emit('layout-change', { + containerWidth, + documentWidth: baseDocumentWidth, + fitZoom, + }); +}; + +watch(superdocContainerWidth, emitLayoutChange); + /** * Scroll the page to a given commentId * diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index c3e3d4c349..c93a41e691 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -82,6 +82,7 @@ import type { SuperDocEditorPayload, SuperDocExceptionPayload, SuperDocExceptionStorePayload, + SuperDocLayoutChangePayload, SuperDocLockedPayload, SuperDocReadyPayload, SuperDocState, @@ -151,6 +152,7 @@ interface SuperDocEventMap { 'whiteboard:enabled': [boolean]; 'whiteboard:tool': [string]; exception: [SuperDocExceptionPayload]; + 'layout-change': [SuperDocLayoutChangePayload]; } // Notes on the event map above: // diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 0b6c087108..8205321943 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1538,6 +1538,19 @@ export type SuperDocExceptionPayload = | SuperDocExceptionRestorePayload | SuperDocExceptionEditorPayload; +/** + * Payload emitted when container dimensions change. Useful for implementing + * fit-to-container zoom behavior. + */ +export interface SuperDocLayoutChangePayload { + /** Current container width in pixels. */ + containerWidth: number; + /** Measured document/page width in pixels. */ + documentWidth: number; + /** Calculated zoom to fit document in available width (unclamped). User should clamp to their preferred min/max. */ + fitZoom: number; +} + export interface Config { /** The ID of the SuperDoc. */ superdocId?: string; diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index b963af241b..ebc02be8ea 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -924,6 +924,18 @@ const init = async () => { currentZoom.value = zoom; }); + superdoc.value?.on('layout-change', ({ fitZoom }) => { + // Clamp zoom between your min/max bounds + console.log('[layout-change]', fitZoom); + if (fitZoom < 50) { + superdoc.value.setZoom(50); + } else if (fitZoom > 200) { + superdoc.value.setZoom(200); + } else { + superdoc.value.setZoom(fitZoom); + } + }); + window.superdoc = superdoc.value; // const ydoc = superdoc.value.ydoc; From f168de5de174dc424e2e2a8dd2c556fe0da83672 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 15:57:52 -0400 Subject: [PATCH 2/3] fix(superdoc): wait for layout ready before capturing base document width Defer base width capture until isReady is true to avoid latching stale measurements before DOCX layout resolves (e.g., landscape or multi-section documents). Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3e1eab610c..93111929f2 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -1323,7 +1323,7 @@ watch(showCommentsSidebar, (value) => { }); // Emit layout-change event when container width changes. -// Capture base document width once at 100% zoom to avoid feedback loops. +// Capture base document width after layout resolves to avoid stale measurements. let baseDocumentWidth = null; let lastEmittedFitZoom = null; @@ -1331,8 +1331,9 @@ const emitLayoutChange = () => { const containerWidth = superdocContainerWidth.value; if (!proxy.$superdoc || containerWidth <= 0) return; - // Capture base width once on first call (document at 100% zoom) + // Wait for document layout to resolve before capturing base width if (baseDocumentWidth === null) { + if (!isReady.value) return; const docEl = superdocRoot.value?.querySelector('.superdoc__document'); const measured = docEl?.clientWidth || docEl?.getBoundingClientRect?.().width || 0; baseDocumentWidth = measured > 0 ? measured : DEFAULT_DOCUMENT_VISIBLE_MIN_WIDTH_PX; @@ -1353,6 +1354,9 @@ const emitLayoutChange = () => { }; watch(superdocContainerWidth, emitLayoutChange); +watch(isReady, (ready) => { + if (ready) emitLayoutChange(); +}); /** * Scroll the page to a given commentId From fd9f34bc6d470a03dcd36aae1834e6d909640475 Mon Sep 17 00:00:00 2001 From: Matthew Connelly Date: Thu, 28 May 2026 16:07:08 -0400 Subject: [PATCH 3/3] test(superdoc): add tests for layout-change event Verify that: - layout-change is not emitted before isReady - payload includes containerWidth, documentWidth, and fitZoom Co-Authored-By: Claude Opus 4.5 --- packages/superdoc/src/SuperDoc.test.js | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 3a9ab1410c..ce3db6285d 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -2625,4 +2625,53 @@ describe('SuperDoc.vue', () => { const styleVars = wrapper.vm.superdocStyleVars; expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88'); }); + + it('does not emit layout-change before isReady', async () => { + const superdocStub = createSuperdocStub(); + superdocStoreStub.isReady.value = false; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + // Set up container width measurement + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + + // Trigger recalculation while not ready + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + // Should not emit before isReady + const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); + expect(layoutChangeCalls.length).toBe(0); + }); + + it('includes documentWidth and fitZoom in layout-change payload when ready', async () => { + const superdocStub = createSuperdocStub(); + superdocStoreStub.isReady.value = true; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + // Set up container width measurement + const rootEl = wrapper.find('.superdoc').element; + const parentEl = rootEl.parentElement; + Object.defineProperty(rootEl, 'clientWidth', { configurable: true, value: 1200 }); + if (parentEl) Object.defineProperty(parentEl, 'clientWidth', { configurable: true, value: 1200 }); + + // Trigger recalculation + wrapper.vm.recalculateCompactCommentsMode(); + await nextTick(); + + const layoutChangeCalls = superdocStub.emit.mock.calls.filter(([name]) => name === 'layout-change'); + if (layoutChangeCalls.length > 0) { + const payload = layoutChangeCalls[layoutChangeCalls.length - 1][1]; + expect(payload).toHaveProperty('containerWidth'); + expect(payload).toHaveProperty('documentWidth'); + expect(payload).toHaveProperty('fitZoom'); + expect(typeof payload.fitZoom).toBe('number'); + } + }); });