This commit is contained in:
Nathan Panchout
2026-05-25 15:27:18 +02:00
parent 0a1e1d018c
commit 48f329fd5b
8 changed files with 852 additions and 68 deletions
@@ -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,
@@ -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';
@@ -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<HTMLElement>('.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<HTMLDivElement | null>;
let cardRef: RefObject<HTMLDivElement | null>;
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();
});
});
@@ -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';
@@ -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<HTMLDivElement>(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')}
>
<Box $css={slideAreaCss}>
<Box $css={slideFrameCss}>
{mountedIndices.map((i) => (
<Box
key={i}
$css={css`
${slideWrapperCss};
${i === currentIndex ? '' : 'display: none;'}
`}
>
<PresenterSlide
blocks={slides[i] as unknown[]}
ariaLabel={t('Slide {{current}} of {{total}}', {
current: i + 1,
total,
})}
/>
</Box>
))}
<Box
$css={isFullscreen ? slideFrameFullscreenCss : slideFrameCss}
ref={frameRef}
>
<Box key={currentIndex} $css={slideWrapperCss}>
<PresenterSlide
blocks={slides[currentIndex]}
frameRef={frameRef}
isFullscreen={isFullscreen}
ariaLabel={t('Slide {{current}} of {{total}}', {
current: currentIndex + 1,
total,
})}
/>
</Box>
</Box>
</Box>
@@ -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<HTMLDivElement | null>;
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<HTMLDivElement>(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<typeof useCreateBlockNote>[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 (
<Box
$css={slideCss}
role="group"
className="titi-presenter-slide"
aria-label={ariaLabel ?? t('Presenter slide')}
>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
</Box>
<>
<DocsEditorStyle />
<Box
style={{
width: hasMeasuredHeight ? DESIGN_WIDTH * scale : 'auto',
height: hasMeasuredHeight ? naturalHeight * scale : 'auto',
margin: 'auto',
}}
role="group"
aria-label={ariaLabel ?? t('Presenter slide')}
>
<Box
ref={cardRef}
$css={cardCss}
style={{
width: DESIGN_WIDTH,
transformOrigin: 'top left',
transform: `scale(${scale})`,
}}
>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
</Box>
</Box>
</>
);
};
@@ -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;
@@ -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<HTMLElement>('.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(<returned 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<HTMLElement | null>,
cardRef: RefObject<HTMLElement | null>,
{
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<HTMLImageElement>();
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 };
};