mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
✨(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:
@@ -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
|
||||
|
||||
+40
-19
@@ -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,
|
||||
|
||||
+9
-3
@@ -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>
|
||||
|
||||
+36
@@ -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;
|
||||
Reference in New Issue
Block a user