mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-22 17:45:12 +02:00
wip
This commit is contained in:
@@ -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';
|
||||
|
||||
+408
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+49
-37
@@ -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>
|
||||
|
||||
|
||||
+90
-29
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user