diff --git a/CHANGELOG.md b/CHANGELOG.md index dc118a1298..e7b131f667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ and this project adheres to - ✨(buildpack) add PaaS deployment support, tested with Scalingo #2293 - 🔧(backend) allow configuring settings OIDC_OP_USER_ENDPOINT_FORMAT - ⚡️(helm) create a dedicated svc and deployment for yprovider converter #2368 +- ✨(frontend) add the presenter mode ### Changed - ♻️(backend) allow global search in sub documents - ✨(backend) add a breadcrumb in the search response +- ♿️(frontend) add aria-hidden to decorative avatar SVGs in share modal #2324 ### Fixed @@ -25,10 +27,6 @@ and this project adheres to - 🐛(backend) prevent admins/owners from overwriting other users comments - 🐛(backend) use computed_link_reach in handle_onboarding_document #2305 -### Changed - -- ♿️(frontend) add aria-hidden to decorative avatar SVGs in share modal #2324 - ## [v5.1.0] - 2026-05-11 ### Added 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 new file mode 100644 index 0000000000..3a5305d527 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -0,0 +1,265 @@ +import { Page, expect, test } from '@playwright/test'; + +import { createDoc, goToGridDoc, mockedDocument } from './utils-common'; +import { openSuggestionMenu, writeInEditor } from './utils-editor'; + +const openPresenter = async (page: Page) => { + await page.getByLabel('Open the document options').click(); + await page.getByRole('menuitem', { name: 'Present' }).click(); + + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); + await expect(overlay).toBeVisible(); + return overlay; +}; + +const insertDivider = async (page: Page) => { + const { suggestionMenu } = await openSuggestionMenu({ page }); + await suggestionMenu.getByText('Divider', { exact: true }).click(); +}; + +const writeMultiSlideDoc = async (page: Page) => { + const editor = await writeInEditor({ page, text: 'Slide one' }); + await editor.press('Enter'); + await insertDivider(page); + await editor.press('Enter'); + await writeInEditor({ page, text: 'Slide two' }); + await editor.press('Enter'); + await insertDivider(page); + await editor.press('Enter'); + await writeInEditor({ page, text: 'Slide three' }); +}; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Presenter Mode', () => { + test('opens the presenter overlay from the doc options menu and closes with Escape', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-open', browserName, 1); + await writeInEditor({ page, text: 'Hello presenter' }); + + const overlay = await openPresenter(page); + + await expect( + overlay.getByRole('toolbar', { name: 'Presenter controls' }), + ).toBeVisible(); + await expect(overlay.getByText('Hello presenter')).toBeVisible(); + + // The presenter calls requestFullscreen on open. usePresenterShortcuts + // deliberately ignores Escape while in fullscreen (the browser owns Esc + // there to exit). Playwright grants requestFullscreen but, unlike a real + // browser, does NOT dispatch a fullscreen exit on Escape — so we must + // exit fullscreen ourselves before pressing Escape to test the close + // path. We also wait for the React state to settle after the exit so + // the keydown listener is re-bound with isFullscreen=false. + await page.evaluate(async () => { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } + }); + await page.waitForTimeout(200); + await page.keyboard.press('Escape'); + await expect(overlay).toBeHidden(); + }); + + test('renders a single-slide doc with counter 1/1 and disabled nav buttons', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-single', browserName, 1); + await writeInEditor({ page, text: 'Slide A' }); + + const overlay = await openPresenter(page); + + await expect(overlay.getByText('1 / 1')).toBeVisible(); + await expect( + overlay.getByRole('button', { name: 'Previous slide' }), + ).toBeDisabled(); + await expect( + overlay.getByRole('button', { name: 'Next slide' }), + ).toBeDisabled(); + await expect(overlay.getByText('Slide A')).toBeVisible(); + + await overlay.getByRole('button', { name: 'Close presenter' }).click(); + await expect(overlay).toBeHidden(); + }); + + test('navigates between slides via the floating bar buttons', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-nav-bar', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + + 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(prev).toBeDisabled(); + await expect(next).toBeEnabled(); + + await next.click(); + await expect(overlay.getByText('2 / 3')).toBeVisible(); + await expect(overlay.getByText('Slide two')).toBeVisible(); + + await next.click(); + await expect(overlay.getByText('3 / 3')).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('Slide two')).toBeVisible(); + }); + + test('navigates between slides via keyboard shortcuts', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-nav-keyboard', browserName, 1); + await writeMultiSlideDoc(page); + + const overlay = await openPresenter(page); + + await expect(overlay.getByText('1 / 3')).toBeVisible(); + + await page.keyboard.press('ArrowRight'); + await expect(overlay.getByText('2 / 3')).toBeVisible(); + + await page.keyboard.press('End'); + await expect(overlay.getByText('3 / 3')).toBeVisible(); + + await page.keyboard.press('Home'); + await expect(overlay.getByText('1 / 3')).toBeVisible(); + + // ArrowLeft on the first slide is clamped — counter stays at 1 / 3. + await page.keyboard.press('ArrowLeft'); + await expect(overlay.getByText('1 / 3')).toBeVisible(); + }); + + test('scales each slide to fit the viewport (outer width = 900 × scale)', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-scaling', browserName, 1); + await writeInEditor({ page, text: 'A short slide' }); + + const overlay = await openPresenter(page); + const currentSlide = overlay.getByRole('group').filter({ hasNotText: '' }); + await expect(currentSlide.first()).toBeVisible(); + + const dims = await currentSlide.first().evaluate((el) => { + // DOM: ... + const stage = el.firstElementChild as HTMLElement | null; + const inner = stage?.firstElementChild as HTMLElement | null; + const innerStyle = inner ? getComputedStyle(inner) : null; + return { + outerWidth: el.getBoundingClientRect().width, + innerTransform: innerStyle?.transform ?? 'none', + }; + }); + + // Inner has `transform: scale()`; match the first scale matrix value. + const m = /matrix\(([-\d.]+)/.exec(dims.innerTransform); + expect( + m, + `expected a scale matrix, got: ${dims.innerTransform}`, + ).not.toBeNull(); + + const scale = m ? parseFloat(m[1]) : NaN; + + // Scale is always clamped to [MIN_SCALE, MAX_SCALE] = [0.7, 1.5]. + // The exact value depends on viewport: sparse content typically saturates + // the height-based scale (→ MAX), but at default Playwright viewport + // (1280) the width path can constrain to scaleW = (1280 - 2*paddingX)/900. + // We assert the bounds, not a specific value. + expect(scale).toBeGreaterThanOrEqual(0.7); + expect(scale).toBeLessThanOrEqual(1.5); + + // The core invariant: outer width = DESIGN_WIDTH (900) × scale, + // within a 5px tolerance for sub-pixel rounding. + expect(Math.abs(dims.outerWidth - 900 * scale)).toBeLessThan(5); + }); + + test('tall slide produces a vertical scrollbar on the outer wrapper with the top visible', async ({ + page, + browserName, + }) => { + await createDoc(page, 'presenter-tall', browserName, 1); + + // Build a tall single-slide doc: many headings + paragraphs so the + // natural content height blows past viewport height at MIN_SCALE. + const editor = await writeInEditor({ page, text: 'TOP MARKER' }); + for (let i = 0; i < 40; i += 1) { + await editor.press('Enter'); + await editor.pressSequentially(`Filler line ${i} to make the slide tall`); + } + + const overlay = await openPresenter(page); + const slide = overlay.getByRole('group').filter({ hasNotText: '' }).first(); + await expect(slide).toBeVisible(); + + // The first block ('TOP MARKER') must be at y=0 of the slide wrapper + // (i.e. visible at the top, not clipped). This is the regression we fix. + const topVisible = await slide.evaluate((el) => { + el.scrollTop = 0; + const first = el.querySelector('.bn-block-content'); + if (!first) { + return { ok: false, reason: 'no first block' }; + } + const slideRect = el.getBoundingClientRect(); + const blockRect = first.getBoundingClientRect(); + return { + ok: blockRect.top >= slideRect.top - 1, + slideTop: slideRect.top, + blockTop: blockRect.top, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; + }); + + expect(topVisible.ok, JSON.stringify(topVisible)).toBe(true); + expect(topVisible.scrollHeight ?? 0).toBeGreaterThan( + topVisible.clientHeight ?? 0, + ); + }); +}); + +test.describe('Presenter Mode mobile', () => { + test.use({ viewport: { width: 500, height: 1200 } }); + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('hides the Present option on small mobile viewports', async ({ + page, + }) => { + await mockedDocument(page, { + abilities: { + destroy: true, + link_configuration: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + accesses_manage: true, + accesses_view: true, + update: true, + partial_update: true, + retrieve: true, + }, + }); + + await goToGridDoc(page); + + await page.getByLabel('Open the document options').click(); + await expect(page.getByRole('menuitem', { name: 'Present' })).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/impress/src/components/modal/SideModal.tsx b/src/frontend/apps/impress/src/components/modal/SideModal.tsx index 9e6047c120..93ca9e18a8 100644 --- a/src/frontend/apps/impress/src/components/modal/SideModal.tsx +++ b/src/frontend/apps/impress/src/components/modal/SideModal.tsx @@ -1,5 +1,9 @@ -import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { ComponentPropsWithRef, PropsWithChildren } from 'react'; +import { + Modal, + ModalDefaultVariantProps, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; +import { PropsWithChildren } from 'react'; import { createGlobalStyle } from 'styled-components'; interface SideModalStyleProps { @@ -35,7 +39,7 @@ const SideModalStyle = createGlobalStyle` } `; -type SideModalType = Omit, 'size'>; +type SideModalType = Omit; type SideModalProps = SideModalType & Partial; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 19973d6c28..af1a1f4557 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -1,5 +1,6 @@ import { Button, useModal } from '@gouvfr-lasuite/cunningham-react'; 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'; @@ -79,6 +80,14 @@ const ModalExport = ) : null; +const PresenterOverlay = dynamic( + () => + import('@/docs/doc-presenter').then((mod) => ({ + default: mod.PresenterOverlay, + })), + { ssr: false }, +); + interface DocToolBoxProps { doc: Doc; } @@ -93,6 +102,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false); + const [isPresenterOpen, setIsPresenterOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); @@ -176,6 +186,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { showSeparator: true, show: !emoji && doc.abilities.partial_update && !isTopRoot, }, + { + label: t('Present'), + icon: , + callback: () => { + setIsPresenterOpen(true); + }, + show: !doc.deleted_at && !isSmallMobile, + testId: `docs-actions-present-${doc.id}`, + }, { label: t('Copy link'), icon: