Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 99 additions & 21 deletions src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
}) => {
Expand All @@ -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,
Expand All @@ -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();
});

Expand All @@ -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 ({
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { usePresenterStore } from '@/docs/doc-presenter/stores';
import { useAuth } from '@/features/auth';
import { useFocusStore, useResponsiveStore } from '@/stores';

Expand Down Expand Up @@ -82,14 +83,6 @@ const ModalExport =
)
: null;

const PresenterOverlay = dynamic(
() =>
import('@/docs/doc-presenter').then((mod) => ({
default: mod.PresenterOverlay,
})),
{ ssr: false },
);

interface DocToolBoxProps {
doc: Doc;
}
Expand All @@ -108,11 +101,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const [isPresenterOpen, setIsPresenterOpen] = useState(false);

const { restoreFocus, addLastFocus } = useFocusStore();
const { isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
// Deep-link (#2397) and slide/URL sync live in PresenterRoot; here we only
// trigger the manual "Present" action.
const openPresenter = usePresenterStore((state) => state.open);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (data) => {
void router.push(`/docs/${data.id}`);
Expand Down Expand Up @@ -148,9 +143,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Present'),
icon: <Present width={24} height={24} aria-hidden="true" />,
callback: () => {
requestAnimationFrame(() => {
setIsPresenterOpen(true);
});
openPresenter(0);
},
isHidden: Boolean(doc.deleted_at) || isMobile,
testId: `docs-actions-present-${doc.id}`,
Expand Down Expand Up @@ -315,14 +308,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
{isPresenterOpen && (
<PresenterOverlay
doc={doc}
onClose={() => {
setIsPresenterOpen(false);
}}
/>
)}
</>
);
};
Loading
Loading