From 33d091d88fa2d3d09bdb18af31e36523ee2bfe22 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Tue, 30 Jun 2026 15:58:40 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20show=20a=20generated=20ti?= =?UTF-8?q?tle=20slide=20before=20the=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../app-impress/presenter-mode.spec.ts | 120 +++++++++++++++--- .../components/PresenterOverlay.tsx | 59 ++++++--- .../components/PresenterSlide.tsx | 12 +- .../components/PresenterTitleSlide.tsx | 36 ++++++ .../src/features/docs/doc-presenter/types.ts | 26 ++++ 5 files changed, 210 insertions(+), 43 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterTitleSlide.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/types.ts diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index a194f5b2e..a4adf6a14 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -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 diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx index b8fb62f5d..62f4b2ad0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx @@ -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(null); + const snapshotRef = useRef(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( + () => [ + { 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(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) => ( ; 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 = ({ > - + {slide.kind === 'title' ? ( + + ) : ( + + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterTitleSlide.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterTitleSlide.tsx new file mode 100644 index 000000000..6ba604de7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterTitleSlide.tsx @@ -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) => ( + + + {title} + + +); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/types.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/types.ts new file mode 100644 index 000000000..a875c59a3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/types.ts @@ -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;