(frontend) show a generated title slide before the content

Prepend a slide with the document title (emoji stripped) so every
presentation opens on a cover, announced to screen readers like any
other slide (#2466).
This commit is contained in:
Nathan Panchout
2026-07-20 09:55:20 +02:00
parent d87b82ef37
commit 33d091d88f
5 changed files with 210 additions and 43 deletions
@@ -1,7 +1,13 @@
import path from 'path';
import { Page, expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import {
openSuggestionMenu,
tryFocusEditorContent,
writeInEditor,
} from './utils-editor';
const openPresenter = async (page: Page) => {
await page.getByLabel('Open the document options').click();
@@ -17,6 +23,29 @@ const insertDivider = async (page: Page) => {
await suggestionMenu.getByText('Divider', { exact: true }).click();
};
const insertImageInCurrentBlock = async (page: Page) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await tryFocusEditorContent({ page });
await page.keyboard.type('/');
await page
.locator('.bn-suggestion-menu')
.getByText('Resizable image with caption', { exact: true })
.click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible({ timeout: 10000 });
return image;
};
const writeMultiSlideDoc = async (page: Page) => {
const editor = await writeInEditor({ page, text: 'Slide one' });
await editor.press('Enter');
@@ -46,6 +75,8 @@ test.describe('Presenter Mode', () => {
await expect(
overlay.getByRole('toolbar', { name: 'Presenter controls' }),
).toBeVisible();
await expect(overlay.getByText(/presenter-open/)).toBeVisible();
await overlay.getByRole('button', { name: 'Next slide' }).click();
await expect(overlay.getByText('Hello presenter')).toBeVisible();
// The presenter calls requestFullscreen on open. usePresenterShortcuts
@@ -92,19 +123,19 @@ test.describe('Presenter Mode', () => {
const overlay = await openPresenter(page);
// The visible "1 / 3" counter is decorative (aria-hidden); the position is
// The visible "1 / 4" counter is decorative (aria-hidden); the position is
// announced through a polite live region for screen readers instead.
// react-aria/live-announcer creates a global role="log" div on document.body
// (outside the dialog), so we query from `page`, not `overlay`.
const liveRegion = page.locator(
'[data-live-announcer="true"] [aria-live="polite"]',
);
// The announcement includes the slide title extracted from the first
// text block (getSlideTitle), so assert the full message.
await expect(liveRegion).toContainText('Slide 1 of 3: Slide one');
// The title slide uses the document title, then content slides use the
// first text block (getSlideTitle).
await expect(liveRegion).toContainText('Slide 1 of 4:');
await overlay.getByRole('button', { name: 'Next slide' }).click();
await expect(liveRegion).toContainText('Slide 2 of 3: Slide two');
await expect(liveRegion).toContainText('Slide 2 of 4: Slide one');
// Each slide advertises a localized role description for screen readers.
await expect(overlay.getByRole('group').first()).toHaveAttribute(
@@ -113,7 +144,7 @@ test.describe('Presenter Mode', () => {
);
});
test('renders a single-slide doc with counter 1/1 and disabled nav buttons', async ({
test('renders a content-only doc after the generated title slide', async ({
page,
browserName,
}) => {
@@ -122,19 +153,58 @@ test.describe('Presenter Mode', () => {
const overlay = await openPresenter(page);
await expect(overlay.getByText('1 / 1')).toBeVisible();
await expect(overlay.getByText('1 / 2')).toBeVisible();
await expect(
overlay.getByRole('button', { name: 'Previous slide' }),
).toBeDisabled();
await expect(
overlay.getByRole('button', { name: 'Next slide' }),
).toBeDisabled();
).toBeEnabled();
await overlay.getByRole('button', { name: 'Next slide' }).click();
await expect(overlay.getByText('2 / 2')).toBeVisible();
await expect(overlay.getByText('Slide A')).toBeVisible();
await expect(
overlay.getByRole('button', { name: 'Next slide' }),
).toBeDisabled();
await overlay.getByRole('button', { name: 'Close presenter' }).click();
await expect(overlay).toBeHidden();
});
test('does not show selected-node chrome when the first slide block is an image', async ({
page,
browserName,
}) => {
await createDoc(page, 'presenter-image-first', browserName, 1);
await insertImageInCurrentBlock(page);
const overlay = await openPresenter(page);
await overlay.getByRole('button', { name: 'Next slide' }).click();
const presenterImage = overlay.locator('img.bn-visual-media').first();
await expect(presenterImage).toBeAttached({ timeout: 10000 });
const outline = await presenterImage.evaluate((img) => {
const blockContent = img.closest('.bn-block-content');
blockContent?.classList.add('ProseMirror-selectednode');
const outlinedElement =
(blockContent?.firstElementChild as HTMLElement | null) ??
(img as HTMLElement);
const style = getComputedStyle(outlinedElement);
return {
outlineStyle: style.outlineStyle,
outlineWidth: style.outlineWidth,
};
});
expect(outline).toEqual({
outlineStyle: 'none',
outlineWidth: '0px',
});
});
test('navigates between slides via the floating bar buttons', async ({
page,
browserName,
@@ -147,23 +217,27 @@ test.describe('Presenter Mode', () => {
const prev = overlay.getByRole('button', { name: 'Previous slide' });
const next = overlay.getByRole('button', { name: 'Next slide' });
await expect(overlay.getByText('1 / 3')).toBeVisible();
await expect(overlay.getByText('Slide one')).toBeVisible();
await expect(overlay.getByText('1 / 4')).toBeVisible();
await expect(overlay.getByText(/presenter-nav-bar/)).toBeVisible();
await expect(prev).toBeDisabled();
await expect(next).toBeEnabled();
await next.click();
await expect(overlay.getByText('2 / 3')).toBeVisible();
await expect(overlay.getByText('2 / 4')).toBeVisible();
await expect(overlay.getByText('Slide one')).toBeVisible();
await next.click();
await expect(overlay.getByText('3 / 4')).toBeVisible();
await expect(overlay.getByText('Slide two')).toBeVisible();
await next.click();
await expect(overlay.getByText('3 / 3')).toBeVisible();
await expect(overlay.getByText('4 / 4')).toBeVisible();
await expect(overlay.getByText('Slide three')).toBeVisible();
await expect(next).toBeDisabled();
await expect(prev).toBeEnabled();
await prev.click();
await expect(overlay.getByText('2 / 3')).toBeVisible();
await expect(overlay.getByText('3 / 4')).toBeVisible();
await expect(overlay.getByText('Slide two')).toBeVisible();
});
@@ -176,20 +250,20 @@ test.describe('Presenter Mode', () => {
const overlay = await openPresenter(page);
await expect(overlay.getByText('1 / 3')).toBeVisible();
await expect(overlay.getByText('1 / 4')).toBeVisible();
await page.keyboard.press('ArrowRight');
await expect(overlay.getByText('2 / 3')).toBeVisible();
await expect(overlay.getByText('2 / 4')).toBeVisible();
await page.keyboard.press('End');
await expect(overlay.getByText('3 / 3')).toBeVisible();
await expect(overlay.getByText('4 / 4')).toBeVisible();
await page.keyboard.press('Home');
await expect(overlay.getByText('1 / 3')).toBeVisible();
await expect(overlay.getByText('1 / 4')).toBeVisible();
// ArrowLeft on the first slide is clamped — counter stays at 1 / 3.
// ArrowLeft on the first slide is clamped — counter stays at 1 / 4.
await page.keyboard.press('ArrowLeft');
await expect(overlay.getByText('1 / 3')).toBeVisible();
await expect(overlay.getByText('1 / 4')).toBeVisible();
});
test('scales each slide to fit the viewport (outer width = 900 × scale)', async ({
@@ -251,7 +325,11 @@ test.describe('Presenter Mode', () => {
}
const overlay = await openPresenter(page);
const slide = overlay.getByRole('group').filter({ hasNotText: '' }).first();
await overlay.getByRole('button', { name: 'Next slide' }).click();
const slide = overlay
.getByRole('group')
.filter({ hasText: 'TOP MARKER' })
.first();
await expect(slide).toBeVisible();
// The first block ('TOP MARKER') must be at y=0 of the slide wrapper
@@ -7,18 +7,20 @@ import { css } from 'styled-components';
import { Box } from '@/components';
import { useEditorStore } from '@/docs/doc-editor/stores';
import { Doc } from '@/docs/doc-management';
import { Doc, getEmojiAndTitle } from '@/docs/doc-management';
import { PRESENTER_WINDOW_RADIUS } from '../constants';
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
import { getSlideTitle, useSlides } from '../hooks/useSlides';
import type { PresenterBlock, PresenterSlideData } from '../types';
import { PresenterFloatingBar } from './PresenterFloatingBar';
import { PresenterSlide } from './PresenterSlide';
interface PresenterOverlayProps {
doc: Doc;
initialSlideIndex: number;
onClose: () => void;
}
@@ -38,8 +40,12 @@ const slideAreaCss = css`
background: white;
`;
const clampSlideIndex = (index: number, total: number) =>
Math.max(0, Math.min(index, Math.max(total - 1, 0)));
export const PresenterOverlay = ({
doc: _doc,
doc,
initialSlideIndex,
onClose,
}: PresenterOverlayProps) => {
const { t } = useTranslation();
@@ -47,20 +53,35 @@ export const PresenterOverlay = ({
// Snapshot the editor's blocks once at mount. Subsequent collaborator
// edits do not affect the ongoing presentation (by design).
const snapshotRef = useRef<unknown[] | null>(null);
const snapshotRef = useRef<PresenterBlock[] | null>(null);
if (snapshotRef.current === null) {
snapshotRef.current = editor ? [...editor.document] : [];
}
const snapshotBlocks = snapshotRef.current;
const slides = useSlides(snapshotBlocks as { type: string }[]);
const [currentIndex, setCurrentIndex] = useState(0);
const total = slides.length;
const clamp = useCallback(
(i: number) => Math.max(0, Math.min(i, total - 1)),
[total],
const contentSlides = useSlides(snapshotBlocks);
const title = useMemo(() => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
return [emoji, titleWithoutEmoji.trim() || t('Untitled document')]
.filter(Boolean)
.join(' ');
}, [doc.title, t]);
const slides = useMemo<PresenterSlideData[]>(
() => [
{ kind: 'title', title, showDividerHint: false },
...contentSlides.map((blocks) => ({ kind: 'content' as const, blocks })),
],
[contentSlides, title],
);
const total = slides.length;
const [currentIndex, setCurrentIndex] = useState(() =>
clampSlideIndex(initialSlideIndex, total),
);
const clamp = useCallback((i: number) => clampSlideIndex(i, total), [total]);
useEffect(() => {
setCurrentIndex(clamp(initialSlideIndex));
}, [initialSlideIndex, clamp]);
const goPrev = useCallback(
() => setCurrentIndex((i) => clamp(i - 1)),
@@ -98,22 +119,22 @@ export const PresenterOverlay = ({
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;
return Array.from({ length: to - from + 1 }, (_, k) => from + k);
}, [currentIndex, total]);
const frameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const title = getSlideTitle(slides[currentIndex] ?? []);
const message = title
const currentSlide = slides[currentIndex];
const slideTitle =
currentSlide?.kind === 'title'
? currentSlide.title
: getSlideTitle(currentSlide?.blocks ?? []);
const message = slideTitle
? t('Slide {{current}} of {{total}}: {{title}}', {
current: currentIndex + 1,
total,
title,
title: slideTitle,
})
: t('Slide {{current}} of {{total}}', {
current: currentIndex + 1,
@@ -139,9 +160,9 @@ export const PresenterOverlay = ({
{mountedIndices.map((i) => (
<PresenterSlide
key={i}
blocks={slides[i] as unknown[]}
frameRef={frameRef}
isCurrent={i === currentIndex}
slide={slides[i]}
ariaLabel={t('Slide {{current}} of {{total}}', {
current: i + 1,
total,
@@ -10,13 +10,15 @@ import {
PRESENTER_SLIDE_FADE_MS,
} from '../constants';
import { useFitScale } from '../hooks/useFitScale';
import { PresenterSlideData } from '../types';
import { PresenterSlideContent } from './PresenterSlideContent';
import { PresenterTitleSlide } from './PresenterTitleSlide';
interface PresenterSlideProps {
blocks: unknown[];
frameRef: RefObject<HTMLDivElement | null>;
isCurrent: boolean;
slide: PresenterSlideData;
ariaLabel?: string;
}
@@ -71,9 +73,9 @@ const innerCss = css`
`;
export const PresenterSlide = ({
blocks,
frameRef,
isCurrent,
slide,
ariaLabel,
}: PresenterSlideProps) => {
const { t } = useTranslation();
@@ -107,7 +109,11 @@ export const PresenterSlide = ({
>
<Box $css={stageCss} style={stageStyle}>
<Box ref={innerRef} $css={innerCss} style={innerStyle}>
<PresenterSlideContent blocks={blocks} />
{slide.kind === 'title' ? (
<PresenterTitleSlide title={slide.title} />
) : (
<PresenterSlideContent blocks={slide.blocks} />
)}
</Box>
</Box>
</Box>
@@ -0,0 +1,36 @@
import { css } from 'styled-components';
import { Box, Text } from '@/components';
interface PresenterTitleSlideProps {
title: string;
}
const titleSlideCss = css`
position: relative;
width: 100%;
min-height: 520px;
box-sizing: border-box;
align-items: center;
justify-content: center;
padding: 96px 32px;
`;
const titleCss = css`
max-width: 720px;
margin: 0;
color: var(--c--contextuals--content--semantic--neutral--primary);
font-size: 40px;
font-weight: 700;
line-height: 48px;
text-align: center;
overflow-wrap: anywhere;
`;
export const PresenterTitleSlide = ({ title }: PresenterTitleSlideProps) => (
<Box $css={titleSlideCss}>
<Text as="h1" $css={titleCss}>
{title}
</Text>
</Box>
);
@@ -0,0 +1,26 @@
import type { Block } from '@blocknote/core/blocks';
import type {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '@/docs/doc-editor/types';
export type PresenterBlock = Block<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>;
export type PresenterTitleSlide = {
kind: 'title';
showDividerHint: boolean;
title: string;
};
export type PresenterContentSlide = {
blocks: PresenterBlock[];
kind: 'content';
};
export type PresenterSlideData = PresenterTitleSlide | PresenterContentSlide;