diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index 89dc45a73..6a534bc90 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -74,6 +74,34 @@ test.describe('Presenter Mode', () => { await expect(overlay).toBeHidden(); }); + test('sizes the first slide correctly on open without navigating away', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-initial-layout', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + + await expect(overlay.getByText('1 / 3')).toBeVisible(); + const slideText = overlay.getByText('Slide one'); + await expect(slideText).toBeVisible(); + + const textBox = await slideText.boundingBox(); + const overlayBox = await overlay.boundingBox(); + expect(textBox).not.toBeNull(); + expect(overlayBox).not.toBeNull(); + if (!textBox || !overlayBox) { + return; + } + + expect(textBox.height).toBeGreaterThan(20); + const textCenterY = textBox.y + textBox.height / 2; + const overlayCenterY = overlayBox.y + overlayBox.height / 2; + const maxOffset = overlayBox.height * 0.35; + expect(Math.abs(textCenterY - overlayCenterY)).toBeLessThan(maxOffset); + }); + test('navigates between slides via the floating bar buttons', async ({ page, browserName, diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index d6454d5bc..af1a1f455 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -1,5 +1,6 @@ import { Button, useModal } from '@gouvfr-lasuite/cunningham-react'; -import { Present, useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { Present } from '@gouvfr-lasuite/ui-kit/icons'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import { useState } from 'react'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts new file mode 100644 index 000000000..305e71179 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts @@ -0,0 +1,408 @@ +import { act, renderHook } from '@testing-library/react'; +import { RefObject } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { DESIGN_WIDTH, FIT_MARGIN, S_MAX, S_MIN } from '../constants'; +import { + measureCardNaturalHeight, + scheduleFitScaleBurst, + useFitScale, +} from '../hooks/useFitScale'; + +type ResizeCallback = () => void; + +class MockResizeObserver { + static instances: MockResizeObserver[] = []; + + callback: ResizeCallback; + observed: Element[] = []; + + constructor(cb: ResizeCallback) { + this.callback = cb; + MockResizeObserver.instances.push(this); + } + + observe(el: Element) { + this.observed.push(el); + } + + unobserve(el: Element) { + this.observed = this.observed.filter((e) => e !== el); + } + + disconnect() { + this.observed = []; + } + + trigger() { + this.callback(); + } + + static last() { + return MockResizeObserver.instances[ + MockResizeObserver.instances.length - 1 + ]; + } + + static reset() { + MockResizeObserver.instances = []; + } +} + +const snap3 = (n: number) => Math.round(n * 1000) / 1000; + +const setFrameDims = (el: HTMLElement, width: number, height: number) => { + Object.defineProperty(el, 'clientWidth', { + configurable: true, + value: width, + }); + Object.defineProperty(el, 'clientHeight', { + configurable: true, + value: height, + }); +}; + +/** + * Drives `measureCardNaturalHeight`. The hook treats a missing `.ProseMirror` + * as "no measurable content yet" (returns 0), so passing 0 here removes the + * child and any non-zero value attaches a ProseMirror with that scrollHeight + * — the same shape BlockNote produces once it mounts. + */ +const setCardHeight = (el: HTMLElement, height: number) => { + let prose = el.querySelector('.ProseMirror'); + if (height === 0) { + if (prose) { + prose.remove(); + } + Object.defineProperty(el, 'offsetHeight', { + configurable: true, + value: 0, + }); + return; + } + if (!prose) { + prose = document.createElement('div'); + prose.className = 'ProseMirror'; + el.appendChild(prose); + } + Object.defineProperty(prose, 'scrollHeight', { + configurable: true, + value: height, + }); + Object.defineProperty(el, 'offsetHeight', { + configurable: true, + value: height, + }); +}; + +describe('measureCardNaturalHeight', () => { + test('uses ProseMirror scroll height plus card padding when present', () => { + const card = document.createElement('div'); + card.style.paddingTop = '40px'; + card.style.paddingBottom = '40px'; + document.body.appendChild(card); + + const prose = document.createElement('div'); + prose.className = 'ProseMirror'; + Object.defineProperty(prose, 'scrollHeight', { value: 300 }); + card.appendChild(prose); + + expect(measureCardNaturalHeight(card)).toBe(380); + + document.body.removeChild(card); + }); + + test('returns 0 when ProseMirror is missing, regardless of card offsetHeight', () => { + const card = document.createElement('div'); + Object.defineProperty(card, 'offsetHeight', { + configurable: true, + value: 420, + }); + expect(measureCardNaturalHeight(card)).toBe(0); + }); + + test('returns 0 when ProseMirror is present but has no measured scrollHeight', () => { + const card = document.createElement('div'); + const prose = document.createElement('div'); + prose.className = 'ProseMirror'; + Object.defineProperty(prose, 'scrollHeight', { value: 0 }); + card.appendChild(prose); + expect(measureCardNaturalHeight(card)).toBe(0); + }); +}); + +describe('useFitScale', () => { + let frame: HTMLDivElement; + let card: HTMLDivElement; + let frameRef: RefObject; + let cardRef: RefObject; + + beforeEach(() => { + MockResizeObserver.reset(); + vi.stubGlobal('ResizeObserver', MockResizeObserver); + frame = document.createElement('div'); + card = document.createElement('div'); + frameRef = { current: frame }; + cardRef = { current: card }; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('caps at S_MAX when the frame is much wider than the design canvas and content is short', () => { + setFrameDims(frame, DESIGN_WIDTH * (S_MAX + 1), 5000); + setCardHeight(card, 400); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe(S_MAX); + expect(result.current.naturalHeight).toBe(400); + }); + + test('fits to frame width when frame is narrower than design canvas at S_MAX', () => { + setFrameDims(frame, 1000, 5000); + setCardHeight(card, 400); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + const expected = snap3(1000 / DESIGN_WIDTH); + expect(result.current.scale).toBe(expected); + }); + + test('shrinks below the width-fit when content is too tall', () => { + setFrameDims(frame, 2000, 900); + setCardHeight(card, 1000); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + const expected = snap3((900 * FIT_MARGIN) / 1000); + expect(result.current.scale).toBe(expected); + }); + + test('floors at S_MIN when content is far too tall', () => { + setFrameDims(frame, 1000, 400); + setCardHeight(card, 5000); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe(S_MIN); + }); + + test('keeps the initial state when the frame is empty', () => { + setFrameDims(frame, 0, 0); + setCardHeight(card, 400); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe(1); + expect(result.current.naturalHeight).toBe(0); + }); + + test('keeps the initial state when the card has no measurable height', () => { + setFrameDims(frame, 1000, 800); + setCardHeight(card, 0); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe(1); + expect(result.current.naturalHeight).toBe(0); + }); + + test('ignores card offsetHeight before ProseMirror mounts (initial-mount race)', () => { + // Repro of the presenter-mode init bug: on first useLayoutEffect, BlockNote + // has not yet inserted .ProseMirror. The card's offsetHeight is polluted + // by the surrounding flex layout (≈ frame.height * 0.95), which would + // snap the scale near 1 if used as the natural height. Once ProseMirror + // appears, the scale should jump to S_MAX. + setFrameDims(frame, 1440, 900); + Object.defineProperty(card, 'offsetHeight', { + configurable: true, + value: Math.round(900 * 0.95), + }); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe(1); + expect(result.current.naturalHeight).toBe(0); + + act(() => { + setCardHeight(card, 400); + MockResizeObserver.last().trigger(); + }); + expect(result.current.naturalHeight).toBe(400); + expect(result.current.scale).toBe(S_MAX); + }); + + test('remeasure updates scale when the card gains height', () => { + setFrameDims(frame, 1000, 800); + setCardHeight(card, 0); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.naturalHeight).toBe(0); + + act(() => { + setCardHeight(card, 400); + result.current.remeasure(); + }); + expect(result.current.naturalHeight).toBe(400); + expect(result.current.scale).toBeGreaterThan(0); + }); + + test('recomputes when ResizeObserver fires with valid dimensions', () => { + setFrameDims(frame, 1000, 800); + setCardHeight(card, 0); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.naturalHeight).toBe(0); + + act(() => { + setCardHeight(card, 500); + MockResizeObserver.last().trigger(); + }); + expect(result.current.naturalHeight).toBe(500); + }); + + test('schedules a double requestAnimationFrame recompute on mount', () => { + const rafSpy = vi.fn((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('requestAnimationFrame', rafSpy); + + setFrameDims(frame, 1200, 1000); + setCardHeight(card, 600); + + renderHook(() => useFitScale(frameRef, cardRef)); + + expect(rafSpy).toHaveBeenCalled(); + }); + + test('recomputes when the frame is resized', () => { + setFrameDims(frame, 1200, 1000); + setCardHeight(card, 600); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + const wideExpected = Math.min(S_MAX, snap3(1200 / DESIGN_WIDTH)); + expect(result.current.scale).toBe(wideExpected); + + act(() => { + setFrameDims(frame, 1200, 500); + MockResizeObserver.last().trigger(); + }); + expect(result.current.scale).toBe(snap3((500 * FIT_MARGIN) / 600)); + }); + + test('does not re-render when the scale change is below the epsilon', () => { + setFrameDims(frame, 1200, 900); + setCardHeight(card, 1000); + + let renderCount = 0; + const { result } = renderHook(() => { + renderCount += 1; + return useFitScale(frameRef, cardRef); + }); + const settled = snap3((900 * FIT_MARGIN) / 1000); + expect(result.current.scale).toBe(settled); + const renderCountAfterStabilization = renderCount; + + act(() => { + setFrameDims(frame, 1200, 901); + MockResizeObserver.last().trigger(); + }); + expect(result.current.scale).toBe(settled); + expect(renderCount).toBe(renderCountAfterStabilization); + }); + + test('reports the natural unscaled height regardless of the applied scale', () => { + setFrameDims(frame, 1000, 500); + setCardHeight(card, 1500); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.naturalHeight).toBe(1500); + const expected = snap3( + Math.max(S_MIN, Math.min(S_MAX, (500 * FIT_MARGIN) / 1500)), + ); + expect(result.current.scale).toBe(expected); + }); + + test('recomputes on window resize even when ResizeObserver does not fire', () => { + setFrameDims(frame, 1200, 1000); + setCardHeight(card, 600); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe( + Math.min(S_MAX, snap3(1200 / DESIGN_WIDTH)), + ); + + act(() => { + setFrameDims(frame, 1200, 500); + window.dispatchEvent(new Event('resize')); + }); + expect(result.current.scale).toBe(snap3((500 * FIT_MARGIN) / 600)); + }); + + test('recomputes on document fullscreenchange', () => { + vi.useFakeTimers(); + setFrameDims(frame, 1200, 1000); + setCardHeight(card, 600); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + expect(result.current.scale).toBe( + Math.min(S_MAX, snap3(1200 / DESIGN_WIDTH)), + ); + + act(() => { + setFrameDims(frame, 1200, 500); + document.dispatchEvent(new Event('fullscreenchange')); + vi.runAllTimers(); + }); + expect(result.current.scale).toBe(snap3((500 * FIT_MARGIN) / 600)); + vi.useRealTimers(); + }); + + test('recomputes when viewportEpoch changes (fullscreen toggle)', () => { + vi.useFakeTimers(); + setFrameDims(frame, 1000, 800); + setCardHeight(card, 400); + + const { result, rerender } = renderHook( + ({ fs }: { fs: boolean }) => + useFitScale(frameRef, cardRef, { viewportEpoch: fs }), + { initialProps: { fs: false } }, + ); + expect(result.current.scale).toBe(snap3(1000 / DESIGN_WIDTH)); + + act(() => { + setFrameDims(frame, 1600, 900); + rerender({ fs: true }); + vi.runAllTimers(); + }); + expect(result.current.scale).toBe( + Math.min(S_MAX, snap3(1600 / DESIGN_WIDTH)), + ); + vi.useRealTimers(); + }); + + test('forces a scale update when the frame size changes beyond epsilon', () => { + setFrameDims(frame, 1200, 1000); + setCardHeight(card, 600); + + const { result } = renderHook(() => useFitScale(frameRef, cardRef)); + const initialScale = result.current.scale; + + act(() => { + setFrameDims(frame, 1600, 1000); + MockResizeObserver.last().trigger(); + }); + expect(result.current.scale).toBeGreaterThan(initialScale); + }); +}); + +describe('scheduleFitScaleBurst', () => { + test('invokes the callback immediately and on delayed timers', () => { + vi.useFakeTimers(); + const run = vi.fn(); + const cleanup = scheduleFitScaleBurst(run); + expect(run).toHaveBeenCalledTimes(1); + act(() => { + vi.runAllTimers(); + }); + expect(run.mock.calls.length).toBeGreaterThan(3); + cleanup(); + vi.useRealTimers(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx index be37d953f..4dd7ded55 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx @@ -4,7 +4,7 @@ import { ChevronRight, Maximize, XMark, -} from '@gouvfr-lasuite/ui-kit'; +} from '@gouvfr-lasuite/ui-kit/icons'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx index 9af30835b..6429a015f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -8,7 +8,6 @@ import { useEditorStore } from '@/docs/doc-editor/stores'; import { Doc } from '@/docs/doc-management'; import { useFocusStore } from '@/stores'; -import { PRESENTER_WINDOW_RADIUS } from '../constants'; import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen'; import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts'; import { useSlides } from '../hooks/useSlides'; @@ -38,27 +37,47 @@ const slideAreaCss = css` overflow: hidden; `; -const slideFrameCss = css` - width: min(80%, 1800px); +const slideFrameBaseCss = css` height: 100%; background: white; overflow-y: auto; overflow-x: hidden; - justify-content: center; align-items: center; + /* No \`justify-content: center\` here. The Box defaults to + * \`display: flex; flex-direction: column\`, so justify-content would + * center items along the main (vertical) axis. When a slide is taller + * than the frame, centering an overflowing flex item clips the top — + * the scroll cannot reach above position 0. Letting items start at the + * top is safe in both cases: the slideWrapper has \`min-height: 100%\`, + * so for short content it already fills the frame; long content scrolls + * naturally from the top. */ +`; + +const slideFrameCss = css` + ${slideFrameBaseCss}; + width: min(80%, 1800px); @media (max-width: 1000px) { width: 95%; } `; +const slideFrameFullscreenCss = css` + ${slideFrameBaseCss}; + width: 100%; + max-width: none; +`; + const slideWrapperCss = css` width: 100%; - max-height: 100%; + min-height: 100%; box-sizing: border-box; display: flex; - align-items: center; - justify-content: center; + /* No \`align-items\` / \`justify-content\` here: the centerer inside uses + * \`margin: auto\`, which distributes leftover space safely. When the + * centerer fits, the auto margins center it; when it overflows, the + * margins collapse to 0 instead of producing a negative offset, so the + * top of the content stays reachable from scrollTop=0. */ `; export const PresenterOverlay = ({ @@ -90,6 +109,7 @@ export const PresenterOverlay = ({ const slides = useSlides(snapshotBlocks as { type: string }[]); const [currentIndex, setCurrentIndex] = useState(0); + const frameRef = useRef(null); const total = slides.length; const clamp = useCallback( @@ -114,8 +134,13 @@ export const PresenterOverlay = ({ const { isFullscreen, enter, exitIfOwned, toggle } = useBrowserFullscreen(); useEffect(() => { - void enter(); + // Defer fullscreen until the overlay layout has committed so the slide + // frame has non-zero dimensions for the first useFitScale pass. + const rafId = requestAnimationFrame(() => { + // void enter(); + }); return () => { + cancelAnimationFrame(rafId); void exitIfOwned(); }; }, [enter, exitIfOwned]); @@ -130,16 +155,6 @@ export const PresenterOverlay = ({ isFullscreen, }); - const mountedIndices = useMemo(() => { - const from = Math.max(0, currentIndex - PRESENTER_WINDOW_RADIUS); - const to = Math.min(total - 1, currentIndex + PRESENTER_WINDOW_RADIUS); - const indices: number[] = []; - for (let i = from; i <= to; i += 1) { - indices.push(i); - } - return indices; - }, [currentIndex, total]); - if (typeof document === 'undefined') { return null; } @@ -152,24 +167,21 @@ export const PresenterOverlay = ({ aria-label={t('Presenter mode')} > - - {mountedIndices.map((i) => ( - - - - ))} + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx index 5a66add36..b8b7abbe5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx @@ -1,40 +1,55 @@ import { BlockNoteView } from '@blocknote/mantine'; import { useCreateBlockNote } from '@blocknote/react'; +import { RefObject, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box } from '@/components'; import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor'; -import { cssEditor } from '@/docs/doc-editor/styles'; +import { DocsEditorStyle } from '@/docs/doc-editor/styles'; + +import { DESIGN_WIDTH, S_MAX, S_MIN } from '../constants'; +import { useFitScale } from '../hooks/useFitScale'; interface PresenterSlideProps { blocks: unknown[]; + frameRef: RefObject; + isFullscreen: boolean; ariaLabel?: string; } -const slideCss = css` - ${cssEditor}; - width: fit-content; - max-width: 100%; - margin: 0 auto; - padding: 0 1.5rem; - /* Scale the slide content so it reads larger than the regular editor. - * Using \`zoom\` (not \`transform: scale\`) so parent scroll, hit testing - * and overflow detection account for the upscaled size. */ - zoom: 1.3; - /* Hide editor chrome that may leak through despite editable={false} */ +const cardCss = css` + min-height: 0; + padding: 2.5rem 3.5rem; .bn-side-menu, .bn-formatting-toolbar, .bn-slash-menu { display: none !important; } + .bn-container, + .bn-root .bn-editor { + height: auto !important; + min-height: 0 !important; + } `; -export const PresenterSlide = ({ blocks, ariaLabel }: PresenterSlideProps) => { +export const PresenterSlide = ({ + blocks, + frameRef, + isFullscreen, + ariaLabel, +}: PresenterSlideProps) => { const { t } = useTranslation(); + const cardRef = useRef(null); + const { scale, naturalHeight, remeasure } = useFitScale(frameRef, cardRef, { + designWidth: DESIGN_WIDTH, + minScale: S_MIN, + maxScale: S_MAX, + viewportEpoch: isFullscreen, + }); + const editor = useCreateBlockNote({ initialContent: - // BlockNote rejects an empty initialContent array — fall back to one empty paragraph. blocks.length > 0 ? (blocks as NonNullable< Parameters[0] @@ -43,21 +58,67 @@ export const PresenterSlide = ({ blocks, ariaLabel }: PresenterSlideProps) => { schema: blockNoteSchema, }); + // BlockNote inserts ProseMirror in a useEffect that fires after + // useFitScale's useLayoutEffect — its onMount is the deterministic signal + // that the editor DOM is in place, so we wait for it before kicking off + // the first fit pass. Two rAFs after let styled-components rules and the + // surrounding flex layout settle before the final measurement. + useEffect(() => { + let rafId1 = 0; + let rafId2 = 0; + const unsubscribe = editor.onMount(() => { + remeasure(); + if (typeof requestAnimationFrame !== 'undefined') { + rafId1 = requestAnimationFrame(() => { + remeasure(); + rafId2 = requestAnimationFrame(remeasure); + }); + } + }); + return () => { + unsubscribe(); + if (rafId1) { + cancelAnimationFrame(rafId1); + } + if (rafId2) { + cancelAnimationFrame(rafId2); + } + }; + }, [editor, remeasure]); + + const hasMeasuredHeight = naturalHeight > 0; + return ( - - - + <> + + + + + + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts index 508aa025e..d7f90e54c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts @@ -5,3 +5,24 @@ * memory and navigation flash. Tune freely. */ export const PRESENTER_WINDOW_RADIUS = 1; + +/** + * Natural (unscaled) width of a slide's design canvas, in CSS pixels. + * Stays constant; the visual width is `DESIGN_WIDTH * scale` and changes + * only via the scale factor — same approach as Notion's slide rendering. + */ +export const DESIGN_WIDTH = 960; +/** Largest scale applied when the frame is much wider than the design canvas. */ +export const S_MAX = 1.3; +/** Smallest scale before we stop shrinking and let the frame scroll instead. */ +export const S_MIN = 0.7; +/** + * Vertical breathing-room factor applied to the height-fit budget. The + * horizontal axis fills up to `S_MAX` without margin so the card hugs the + * frame width when it can. + */ +export const FIT_MARGIN = 0.95; +/** Minimum scale delta required before re-rendering with a new value. */ +export const FIT_EPSILON = 0.005; +/** Snap precision for the computed scale, keeps the RO loop convergent. */ +export const FIT_DECIMALS = 3; diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useFitScale.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useFitScale.ts new file mode 100644 index 000000000..703455321 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useFitScale.ts @@ -0,0 +1,253 @@ +import { + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import { + DESIGN_WIDTH, + FIT_DECIMALS, + FIT_EPSILON, + FIT_MARGIN, + S_MAX, + S_MIN, +} from '../constants'; + +interface UseFitScaleOptions { + designWidth?: number; + minScale?: number; + maxScale?: number; + /** Bumped when the slide frame viewport changes (e.g. fullscreen toggle). */ + viewportEpoch?: boolean; +} + +/** Delays for remeasure after viewport transitions (fullscreen lags layout). */ +const VIEWPORT_REMEASURE_DELAYS_MS = [0, 50, 150, 300, 500] as const; + +export const scheduleFitScaleBurst = (run: () => void): (() => void) => { + run(); + let rafId1 = 0; + let rafId2 = 0; + if (typeof requestAnimationFrame !== 'undefined') { + rafId1 = requestAnimationFrame(() => { + run(); + rafId2 = requestAnimationFrame(run); + }); + } + const timeouts = VIEWPORT_REMEASURE_DELAYS_MS.map((ms) => + window.setTimeout(run, ms), + ); + return () => { + if (rafId1) { + cancelAnimationFrame(rafId1); + } + if (rafId2) { + cancelAnimationFrame(rafId2); + } + timeouts.forEach((id) => window.clearTimeout(id)); + }; +}; + +export interface FitScaleResult { + scale: number; + naturalHeight: number; + remeasure: () => void; +} + +const snap = (value: number) => { + const factor = 10 ** FIT_DECIMALS; + return Math.round(value * factor) / factor; +}; + +/** + * Content height for scale fitting. Returns 0 until ProseMirror is in the + * DOM so callers defer the first fit until BlockNote has mounted — the card's + * own offsetHeight can be polluted by inherited flex layout before then, + * locking the computed scale at a wrong value. + */ +export const measureCardNaturalHeight = (card: HTMLElement): number => { + const prose = card.querySelector('.ProseMirror'); + if (!prose) { + return 0; + } + const contentH = prose.scrollHeight || prose.offsetHeight; + if (contentH <= 0) { + return 0; + } + const style = getComputedStyle(card); + const padY = + (parseFloat(style.paddingTop) || 0) + + (parseFloat(style.paddingBottom) || 0); + return contentH + padY; +}; + +/** + * Computes a uniform `transform: scale()` factor that fits a fixed-width + * design canvas inside `frameRef`. The card mounted at `cardRef` must + * already have `width: designWidth` and `transform: scale()` + * applied with `transform-origin: top left`. Because `transform` does not + * affect layout, the measured natural height is taken from the editor content + * (`.ProseMirror`) plus card padding when available. + * + * Returns { scale, naturalHeight, remeasure } so the caller can size a wrapper + * at the post-scale dimensions (designWidth * scale × naturalHeight * scale). + */ +export const useFitScale = ( + frameRef: RefObject, + cardRef: RefObject, + { + designWidth = DESIGN_WIDTH, + minScale = S_MIN, + maxScale = S_MAX, + viewportEpoch, + }: UseFitScaleOptions = {}, +): FitScaleResult => { + const [state, setState] = useState({ + scale: 1, + naturalHeight: 0, + }); + const stateRef = useRef(state); + const lastFrameSizeRef = useRef({ w: 0, h: 0 }); + const recomputeRef = useRef<(() => void) | null>(null); + + const remeasure = useCallback(() => { + recomputeRef.current?.(); + }, []); + + useLayoutEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + const frame = frameRef.current; + const card = cardRef.current; + if (!frame || !card) { + return; + } + + const recompute = () => { + const fW = frame.clientWidth; + const fH = frame.clientHeight; + if (fW === 0 || fH === 0) { + return; + } + const naturalH = measureCardNaturalHeight(card); + if (naturalH === 0) { + return; + } + const sFitW = fW / designWidth; + const sFitH = (fH * FIT_MARGIN) / naturalH; + const raw = Math.min(maxScale, sFitW, sFitH); + const nextScale = snap(Math.max(minScale, raw)); + const prev = stateRef.current; + const frameChanged = + Math.abs(fW - lastFrameSizeRef.current.w) > 2 || + Math.abs(fH - lastFrameSizeRef.current.h) > 2; + lastFrameSizeRef.current = { w: fW, h: fH }; + if ( + !frameChanged && + Math.abs(nextScale - prev.scale) < FIT_EPSILON && + naturalH === prev.naturalHeight + ) { + return; + } + const next = { scale: nextScale, naturalHeight: naturalH }; + stateRef.current = next; + setState(next); + }; + + recomputeRef.current = recompute; + + const observer = new ResizeObserver(recompute); + observer.observe(frame); + observer.observe(card); + + const watched = new WeakSet(); + const onImageLoad = () => recompute(); + const attachImageListeners = () => { + card.querySelectorAll('img').forEach((img) => { + if (watched.has(img) || img.complete) { + return; + } + watched.add(img); + img.addEventListener('load', onImageLoad, { once: true }); + }); + }; + + const mutation = + typeof MutationObserver !== 'undefined' + ? new MutationObserver(() => { + attachImageListeners(); + recompute(); + }) + : null; + mutation?.observe(card, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + const onViewportChange = () => scheduleFitScaleBurst(recompute); + if (typeof window !== 'undefined') { + window.addEventListener('resize', onViewportChange); + window.visualViewport?.addEventListener('resize', onViewportChange); + } + if (typeof document !== 'undefined') { + document.addEventListener('fullscreenchange', onViewportChange); + } + + attachImageListeners(); + recompute(); + + let rafId1 = 0; + let rafId2 = 0; + if (typeof requestAnimationFrame !== 'undefined') { + rafId1 = requestAnimationFrame(() => { + recompute(); + rafId2 = requestAnimationFrame(recompute); + }); + } + + // BlockNote/ProseMirror often mounts after the first layout pass. + let pollCount = 0; + const pollId = window.setInterval(() => { + pollCount += 1; + recompute(); + if (stateRef.current.naturalHeight > 0 || pollCount >= 40) { + window.clearInterval(pollId); + } + }, 50); + + return () => { + recomputeRef.current = null; + window.clearInterval(pollId); + if (rafId1) { + cancelAnimationFrame(rafId1); + } + if (rafId2) { + cancelAnimationFrame(rafId2); + } + observer.disconnect(); + mutation?.disconnect(); + if (typeof window !== 'undefined') { + window.removeEventListener('resize', onViewportChange); + window.visualViewport?.removeEventListener('resize', onViewportChange); + } + if (typeof document !== 'undefined') { + document.removeEventListener('fullscreenchange', onViewportChange); + } + }; + }, [frameRef, cardRef, designWidth, minScale, maxScale]); + + useEffect(() => { + if (viewportEpoch === undefined) { + return; + } + return scheduleFitScaleBurst(() => recomputeRef.current?.()); + }, [viewportEpoch]); + + return { ...state, remeasure }; +};