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 a194f5b2e0..a4adf6a142 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-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
index d052dcb101..8ad47dda48 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
@@ -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';
@@ -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;
}
@@ -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}`);
@@ -148,9 +143,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
label: t('Present'),
icon: ,
callback: () => {
- requestAnimationFrame(() => {
- setIsPresenterOpen(true);
- });
+ openPresenter(0);
},
isHidden: Boolean(doc.deleted_at) || isMobile,
testId: `docs-actions-present-${doc.id}`,
@@ -315,14 +308,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
- {isPresenterOpen && (
- {
- setIsPresenterOpen(false);
- }}
- />
- )}
>
);
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts
index 7409aa7870..c5cd534696 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts
@@ -1,13 +1,40 @@
import { describe, expect, test } from 'vitest';
import { getSlideTitle, splitBlocksIntoSlides } from '../hooks/useSlides';
+import type { PresenterBlock } from '../types';
-const para = (text = 'hello') => ({
- type: 'paragraph',
- content: text === '' ? [] : [{ type: 'text', text }],
+type TestBlock = PresenterBlock;
+
+const block = (
+ type: string,
+ options: Partial = {},
+): TestBlock =>
+ ({
+ children: [],
+ id: type,
+ props: {},
+ type,
+ ...options,
+ }) as PresenterBlock;
+
+const textContent = (text: string) => ({
+ styles: {},
+ text,
+ type: 'text' as const,
});
-const divider = () => ({ type: 'divider' });
-const image = () => ({ type: 'image', props: { url: 'x' } });
+
+const para = (text = 'hello'): TestBlock =>
+ block('paragraph', {
+ content: text === '' ? [] : [textContent(text)],
+ });
+
+const divider = (children: TestBlock[] = []): TestBlock =>
+ block('divider', { children });
+
+const image = (): TestBlock => block('image', { props: { url: 'x' } });
+
+const quote = (text: string): TestBlock =>
+ block('quote', { content: [textContent(text)] });
describe('splitBlocksIntoSlides', () => {
test('no divider yields one slide', () => {
@@ -56,19 +83,84 @@ describe('splitBlocksIntoSlides', () => {
expect(result[0]).toHaveLength(0);
});
- test('empty paragraphs are preserved as intentional spacing', () => {
+ test('leading empty paragraphs after dividers are removed', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
para(''),
para(' '),
+ para('b'),
+ ]);
+ expect(result).toHaveLength(2);
+ expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
+ expect(result[1]).toHaveLength(1);
+ expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
+ });
+
+ test('empty paragraphs after content are preserved as intentional spacing', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
divider(),
para('b'),
+ para(''),
+ para(' '),
]);
- expect(result).toHaveLength(3);
+ expect(result).toHaveLength(2);
+ expect(result[1]).toHaveLength(3);
+ expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
+ });
+
+ test('trailing empty paragraphs before dividers are removed', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ para(''),
+ para(' '),
+ divider(),
+ para('b'),
+ ]);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toHaveLength(1);
expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
- expect(result[1]).toHaveLength(2);
- expect(result[2][0]).toMatchObject({ content: [{ text: 'b' }] });
+ expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
+ });
+
+ test('empty paragraphs between content blocks before a divider are preserved', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ para(''),
+ para('b'),
+ divider(),
+ para('c'),
+ ]);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toHaveLength(3);
+ expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
+ expect(result[0][2]).toMatchObject({ content: [{ text: 'b' }] });
+ });
+
+ test('leading empty children under structural dividers are removed', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ divider([para(''), para(' '), para('b')]),
+ ]);
+ expect(result).toHaveLength(2);
+ expect(result[1]).toMatchObject([
+ { type: 'divider', children: [{ content: [{ text: 'b' }] }] },
+ ]);
+ });
+
+ test('trailing empty children under structural dividers are removed before dividers', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ divider([para('b'), para(''), para(' ')]),
+ divider(),
+ para('c'),
+ ]);
+ expect(result).toHaveLength(3);
+ expect(result[1]).toMatchObject([
+ { type: 'divider', children: [{ content: [{ text: 'b' }] }] },
+ ]);
+ expect(result[2][0]).toMatchObject({ content: [{ text: 'c' }] });
});
test('blocks within a group are kept verbatim, empty or not', () => {
@@ -83,13 +175,35 @@ describe('splitBlocksIntoSlides', () => {
expect(result).toHaveLength(2);
expect(result[1]).toHaveLength(1);
});
-});
-const heading = (text: string) => ({
- type: 'heading',
- content: [{ type: 'text', text }],
+ test('children nested under dividers keep their nesting in the following slides', () => {
+ const hello = para('Hello');
+ const test2 = para('Test 2');
+ const after = quote('dfsqdqsdqs');
+ const firstDivider = divider([hello]);
+ const secondDivider = divider([test2]);
+ const result = splitBlocksIntoSlides([
+ para('Test 1'),
+ firstDivider,
+ secondDivider,
+ after,
+ para('fsdf'),
+ ]);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toMatchObject([{ content: [{ text: 'Test 1' }] }]);
+ expect(result[1]).toEqual([firstDivider]);
+ expect(result[2]).toMatchObject([
+ { type: 'divider', children: [{ content: [{ text: 'Test 2' }] }] },
+ { type: 'quote', content: [{ text: 'dfsqdqsdqs' }] },
+ { content: [{ text: 'fsdf' }] },
+ ]);
+ });
});
+const heading = (text: string): TestBlock =>
+ block('heading', { content: [textContent(text)] });
+
describe('getSlideTitle', () => {
test('returns text from the first heading', () => {
expect(getSlideTitle([para('intro'), heading('My Title')])).toBe(
@@ -106,7 +220,7 @@ describe('getSlideTitle', () => {
});
test('returns empty string when all blocks have empty content', () => {
- expect(getSlideTitle([para(''), { type: 'image' }])).toBe('');
+ expect(getSlideTitle([para(''), image()])).toBe('');
});
test('skips whitespace-only blocks and picks the first with text', () => {
@@ -118,22 +232,25 @@ describe('getSlideTitle', () => {
});
test('extracts text from nested inline content (e.g. links)', () => {
- const linkBlock = {
- type: 'paragraph',
+ const linkBlock = block('paragraph', {
content: [
- { type: 'text', text: 'Visit ' },
+ textContent('Visit '),
{
- type: 'link',
- content: [{ type: 'text', text: 'our site' }],
+ content: [textContent('our site')],
href: 'https://example.com',
+ type: 'link',
},
],
- };
+ });
expect(getSlideTitle([linkBlock])).toBe('Visit our site');
});
test('handles blocks without content property', () => {
- expect(getSlideTitle([{ type: 'divider' }, para('after')])).toBe('after');
+ expect(getSlideTitle([divider(), para('after')])).toBe('after');
+ });
+
+ test('extracts text from blocks nested under structural dividers', () => {
+ expect(getSlideTitle([divider([para('nested text')])])).toBe('nested text');
});
test('trims leading and trailing whitespace', () => {
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 b8fb62f5db..62f4b2ad09 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 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 clamp = useCallback(
- (i: number) => Math.max(0, Math.min(i, total - 1)),
- [total],
+ 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) => (
+ import('./PresenterOverlay').then((mod) => ({
+ default: mod.PresenterOverlay,
+ })),
+ { ssr: false, loading: () => },
+);
+
+/**
+ * Single mount point for the presenter, rendered high in the tree (doc page).
+ * Drives the manual "Present" action via `usePresenterStore`, painting a boot
+ * cover until the editor snapshot is available.
+ */
+export const PresenterRoot = () => {
+ const { isMobile } = useResponsiveStore();
+ const editor = useEditorStore((state) => state.editor);
+ const { currentDoc } = useDocStore();
+ const { initialSlideIndex, isOpen, close } = usePresenterStore();
+
+ const handleClose = useCallback(() => {
+ close();
+ }, [close]);
+
+ useEffect(() => {
+ if (isMobile && isOpen) {
+ close();
+ }
+ }, [isMobile, isOpen, close]);
+
+ useEffect(() => {
+ return () => {
+ close();
+ };
+ }, [close]);
+
+ const active = !isMobile && isOpen;
+ const isBooting = active && (!editor || !currentDoc);
+
+ // Let users escape the boot cover if the editor never finishes loading.
+ useEffect(() => {
+ if (!isBooting) {
+ return;
+ }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ handleClose();
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [isBooting, handleClose]);
+
+ if (!active) {
+ return null;
+ }
+
+ if (!editor || !currentDoc) {
+ return ;
+ }
+
+ return (
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
index df550f4d2a..aa61e072ae 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
@@ -1,11 +1,8 @@
-import { BlockNoteView } from '@blocknote/mantine';
-import { useCreateBlockNote } from '@blocknote/react';
-import { RefObject, useEffect, useRef } from 'react';
+import { RefObject, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
-import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor';
import {
PRESENTER_FRAME_PADDING_Y,
@@ -13,11 +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;
isCurrent: boolean;
+ slide: PresenterSlideData;
ariaLabel?: string;
}
@@ -47,12 +48,6 @@ const outerCss = css`
overflow-x: hidden;
background: white;
transition: opacity ${PRESENTER_SLIDE_FADE_MS}ms ease;
- /* Hide editor chrome that may leak through despite editable={false} */
- .bn-side-menu,
- .bn-formatting-toolbar,
- .bn-slash-menu {
- display: none !important;
- }
`;
// The stage absorbs the un-scaled inner's layout box. Its explicit height
@@ -78,35 +73,13 @@ const innerCss = css`
`;
export const PresenterSlide = ({
- blocks,
frameRef,
isCurrent,
+ slide,
ariaLabel,
}: PresenterSlideProps) => {
const { t } = useTranslation();
const innerRef = useRef(null);
- const editor = useCreateBlockNote({
- initialContent:
- // BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
- blocks.length > 0
- ? (blocks as NonNullable<
- Parameters[0]
- >['initialContent'])
- : undefined,
- schema: blockNoteSchema,
- });
-
- // ProseMirror adds role="textbox" and contenteditable on its root even
- // when editable=false, making the SR announce "editing, autocomplete" on
- // focus. Strip those attributes so the slide reads as plain content.
- useEffect(() => {
- const pm = innerRef.current?.querySelector('.ProseMirror');
- if (pm) {
- pm.removeAttribute('role');
- pm.removeAttribute('contenteditable');
- pm.setAttribute('tabindex', '-1');
- }
- }, []);
const fit = useFitScale(innerRef, frameRef);
@@ -136,14 +109,11 @@ export const PresenterSlide = ({
>
-
+ {slide.kind === 'title' ? (
+
+ ) : (
+
+ )}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx
new file mode 100644
index 0000000000..85f92a6a41
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlideContent.tsx
@@ -0,0 +1,105 @@
+import { BlockNoteView } from '@blocknote/mantine';
+import { useCreateBlockNote } from '@blocknote/react';
+import { CSSProperties, Ref, useEffect, useRef } from 'react';
+import { css } from 'styled-components';
+
+import { Box } from '@/components';
+import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor';
+
+import type { PresenterBlock } from '../types';
+
+interface PresenterSlideContentProps {
+ blocks: PresenterBlock[];
+ className?: string;
+ innerRef?: Ref;
+ style?: CSSProperties;
+}
+
+const slideContentCss = css`
+ .bn-side-menu,
+ .bn-formatting-toolbar,
+ .bn-slash-menu {
+ display: none !important;
+ }
+
+ .bn-block-content.ProseMirror-selectednode > *,
+ .ProseMirror-selectednode > .bn-block-content > *,
+ .ProseMirror-selectednode {
+ outline: 0 !important;
+ }
+
+ .bn-block-content[data-content-type='divider'] {
+ display: none;
+ }
+`;
+
+const setRefValue = (
+ ref: Ref | undefined,
+ node: HTMLDivElement | null,
+) => {
+ if (!ref) {
+ return;
+ }
+
+ if (typeof ref === 'function') {
+ ref(node);
+ return;
+ }
+
+ (ref as { current: HTMLDivElement | null }).current = node;
+};
+
+export const PresenterSlideContent = ({
+ blocks,
+ className,
+ innerRef,
+ style,
+}: PresenterSlideContentProps) => {
+ const contentRef = useRef(null);
+ const editor = useCreateBlockNote({
+ initialContent:
+ // BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
+ blocks.length > 0
+ ? (blocks as NonNullable<
+ Parameters[0]
+ >['initialContent'])
+ : undefined,
+ schema: blockNoteSchema,
+ });
+
+ // Even with `editable={false}`, BlockNote/ProseMirror currently still renders
+ // the editor node with `role="textbox"` and `contenteditable`. For presenter
+ // slides that is wrong - the content is presentation, not an editable field -
+ // and it pollutes the accessibility tree. Strip those, but keep the content
+ // tabbable so keyboard users can reach and scroll long slides. (Observed
+ // BlockNote behaviour, not a guarantee; revisit on upgrades.)
+ useEffect(() => {
+ const pm = contentRef.current?.querySelector('.ProseMirror');
+ if (pm) {
+ pm.removeAttribute('role');
+ pm.removeAttribute('contenteditable');
+ pm.setAttribute('tabindex', '0');
+ }
+ }, []);
+
+ return (
+ {
+ contentRef.current = node;
+ setRefValue(innerRef, node);
+ }}
+ className={className}
+ $css={slideContentCss}
+ style={style}
+ >
+
+
+ );
+};
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 0000000000..6ba604de7f
--- /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/hooks/useSlides.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts
index 8e2ef6724f..2642721cee 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts
@@ -1,48 +1,15 @@
import { useMemo } from 'react';
-type Block = {
- type: string;
- content?: unknown;
- children?: Block[];
-};
-
-/**
- * Split a flat list of top-level blocks into slide groups.
- *
- * - Each `divider` block separates two slides; the divider itself is dropped.
- * - Blocks are otherwise preserved verbatim — including empty paragraphs
- * (intentional spacing) and custom blocks (interlinks, embeds, ...). The
- * presenter renders whatever the editor holds; it does not second-guess
- * the author's content.
- * - Groups with no blocks at all are removed (handles leading, trailing or
- * consecutive dividers).
- * - The returned array is never empty: an empty doc yields one empty group.
- */
-export const splitBlocksIntoSlides = (blocks: T[]): T[][] => {
- const groups: T[][] = [];
- let current: T[] = [];
+import type { PresenterBlock } from '../types';
- for (const block of blocks) {
- if (block.type === 'divider') {
- groups.push(current);
- current = [];
- continue;
- }
- current.push(block);
- }
- groups.push(current);
-
- const nonEmpty = groups.filter((group) => group.length > 0);
-
- return nonEmpty.length > 0 ? nonEmpty : [[]];
-};
-
-export const useSlides = (blocks: T[]): T[][] => {
- return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
+type SlideGroup = {
+ blocks: T[];
+ dividerId?: string;
+ startsAfterDivider?: boolean;
};
// Extract text from a node's inline content for summarization or accessibility.
-const extractInlineText = (content: unknown): string => {
+const extractInlineText = (content: PresenterBlock['content']): string => {
if (typeof content === 'string') {
return content;
}
@@ -55,12 +22,11 @@ const extractInlineText = (content: unknown): string => {
return node;
}
if (node && typeof node === 'object') {
- const inline = node as { text?: unknown; content?: unknown };
- if (typeof inline.text === 'string') {
- return inline.text;
+ if ('text' in node && typeof node.text === 'string') {
+ return node.text;
}
- if (inline.content !== undefined) {
- return extractInlineText(inline.content);
+ if ('content' in node && node.content !== undefined) {
+ return extractInlineText(node.content);
}
}
return '';
@@ -68,14 +34,186 @@ const extractInlineText = (content: unknown): string => {
.join('');
};
+const isEmptyParagraphBlock = (block: PresenterBlock): boolean =>
+ block.type === 'paragraph' &&
+ extractInlineText(block.content).trim().length === 0 &&
+ (block.children ?? []).length === 0;
+
+type TrimSide = 'leading' | 'trailing';
+
+// Drop empty paragraph blocks from one end of the list. The opposite end and
+// any non-empty block (content paragraphs, headings, custom blocks) are left
+// untouched.
+const trimEmptyParagraphs = (
+ blocks: T[],
+ side: TrimSide,
+): T[] => {
+ if (side === 'leading') {
+ const firstContentIndex = blocks.findIndex(
+ (block) => !isEmptyParagraphBlock(block),
+ );
+
+ return firstContentIndex === -1 ? [] : blocks.slice(firstContentIndex);
+ }
+
+ const reversedLastContentIndex = [...blocks]
+ .reverse()
+ .findIndex((block) => !isEmptyParagraphBlock(block));
+
+ return reversedLastContentIndex === -1
+ ? []
+ : blocks.slice(0, blocks.length - reversedLastContentIndex);
+};
+
+// A divider carrying children is kept as the structural parent of the
+// following slide; trim the empty paragraphs from the relevant end of its
+// children. Returns null when nothing renderable remains, and the block
+// unchanged when it is not such a structural divider or needs no trimming.
+const trimStructuralDividerBoundary = (
+ block: T,
+ side: TrimSide,
+): T | null => {
+ if (block.type !== 'divider' || !Array.isArray(block.children)) {
+ return block;
+ }
+
+ const children = trimEmptyParagraphs(block.children, side);
+ if (children.length === 0) {
+ return null;
+ }
+
+ if (children.length === block.children.length) {
+ return block;
+ }
+
+ return { ...block, children };
+};
+
+const stripLeadingEmptyParagraphsAfterDivider = (
+ group: SlideGroup,
+): SlideGroup => {
+ if (!group.startsAfterDivider) {
+ return group;
+ }
+
+ const [firstBlock, ...remainingBlocks] = group.blocks;
+ const firstRenderableBlock = firstBlock
+ ? trimStructuralDividerBoundary(firstBlock, 'leading')
+ : null;
+ const blocks = firstRenderableBlock
+ ? [firstRenderableBlock, ...remainingBlocks]
+ : remainingBlocks;
+
+ return {
+ ...group,
+ blocks: trimEmptyParagraphs(blocks, 'leading'),
+ };
+};
+
+const stripTrailingEmptyParagraphsBeforeDivider = (
+ blocks: T[],
+): T[] => {
+ const lastBlock = blocks[blocks.length - 1];
+ const lastRenderableBlock = lastBlock
+ ? trimStructuralDividerBoundary(lastBlock, 'trailing')
+ : null;
+ const trimmedBlocks = lastRenderableBlock
+ ? [...blocks.slice(0, -1), lastRenderableBlock]
+ : blocks.slice(0, -1);
+
+ return trimEmptyParagraphs(trimmedBlocks, 'trailing');
+};
+
+const getRawSlideGroups = (
+ blocks: T[],
+ options: { stripBeforeDividers?: boolean } = {},
+): SlideGroup[] => {
+ const groups: SlideGroup[] = [{ blocks: [] }];
+ let current = groups[0];
+
+ for (const block of blocks) {
+ if (block.type === 'divider') {
+ if (options.stripBeforeDividers) {
+ current.blocks = stripTrailingEmptyParagraphsBeforeDivider(
+ current.blocks,
+ );
+ }
+ current = {
+ blocks:
+ Array.isArray(block.children) && block.children.length > 0
+ ? [block]
+ : [],
+ dividerId: block.id,
+ startsAfterDivider: true,
+ };
+ groups.push(current);
+ continue;
+ }
+
+ current.blocks.push(block);
+ }
+
+ return groups;
+};
+
+const getRenderedSlideGroups = (
+ blocks: T[],
+): SlideGroup[] =>
+ getRawSlideGroups(blocks, { stripBeforeDividers: true }).map(
+ stripLeadingEmptyParagraphsAfterDivider,
+ );
+
+/**
+ * Split a flat list of top-level blocks into slide groups.
+ *
+ * - Each `divider` block separates two slides. Dividers without children are
+ * dropped.
+ * - A divider with children is kept as a structural parent in the following
+ * slide so BlockNote can preserve the visual indentation line. The presenter
+ * hides the divider's own horizontal rule when rendering the slide.
+ * - Empty paragraphs immediately before or after a divider are dropped so
+ * habitual spacing around slide breaks does not offset the slides. Empty
+ * paragraphs elsewhere are preserved as intentional spacing, and custom
+ * blocks (interlinks, embeds, ...) are kept verbatim.
+ * - Groups with no blocks at all are removed (handles leading, trailing or
+ * consecutive dividers).
+ * - The returned array is never empty: an empty doc yields one empty group.
+ */
+export const splitBlocksIntoSlides = (
+ blocks: T[],
+): T[][] => {
+ const nonEmpty = getRenderedSlideGroups(blocks)
+ .map((group) => group.blocks)
+ .filter((group) => group.length > 0);
+
+ return nonEmpty.length > 0 ? nonEmpty : [[]];
+};
+
+/** Memoized {@link splitBlocksIntoSlides} for use during render. */
+export const useSlides = (blocks: T[]): T[][] => {
+ return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
+};
+
+const getBlocksInReadingOrder = (
+ blocks: T[],
+): PresenterBlock[] => {
+ return blocks.flatMap((block) => [
+ block,
+ ...getBlocksInReadingOrder(block.children ?? []),
+ ]);
+};
+
/** First heading text, or first block with text, for SR announcements. */
-export const getSlideTitle = (
- blocks: { type: string; content?: unknown }[],
-): string => {
- const heading = blocks.find((block) => block.type === 'heading');
+export const getSlideTitle = (blocks: PresenterBlock[]): string => {
+ const blocksInReadingOrder = getBlocksInReadingOrder(blocks);
+ const heading = blocksInReadingOrder.find(
+ (block) => block.type === 'heading',
+ );
const source =
heading ??
- blocks.find((block) => extractInlineText(block.content).trim().length > 0);
+ blocksInReadingOrder.find(
+ (block) => extractInlineText(block.content).trim().length > 0,
+ );
return source ? extractInlineText(source.content).trim() : '';
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
index 0ad4b13a32..fb08387dd0 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
@@ -1 +1,2 @@
-export { PresenterOverlay } from './components/PresenterOverlay';
+export { PresenterRoot } from './components/PresenterRoot';
+export * from './stores';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts
new file mode 100644
index 0000000000..4bcaa1bd4a
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/index.ts
@@ -0,0 +1 @@
+export * from './usePresenterStore';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx
new file mode 100644
index 0000000000..eb39baefaa
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/stores/usePresenterStore.tsx
@@ -0,0 +1,23 @@
+import { create } from 'zustand';
+
+export interface PresenterState {
+ isOpen: boolean;
+ /** 0-based slide to start the presentation on. */
+ initialSlideIndex: number;
+ open: (index?: number) => void;
+ close: () => void;
+}
+
+export const usePresenterStore = create((set) => ({
+ isOpen: false,
+ initialSlideIndex: 0,
+ open: (index = 0) => {
+ const safeSlideIndex = Number.isFinite(index)
+ ? Math.max(0, Math.trunc(index))
+ : 0;
+ set({ isOpen: true, initialSlideIndex: safeSlideIndex });
+ },
+ close: () => {
+ set({ isOpen: false });
+ },
+}));
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 0000000000..a875c59a39
--- /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;
diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
index 3d4d7193df..5eb2933677 100644
--- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
+++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
@@ -18,6 +18,7 @@ import {
useTrans,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
+import { PresenterRoot } from '@/features/docs/doc-presenter';
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
import { DocEditorSkeleton, useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
@@ -68,6 +69,7 @@ export function DocLayout() {
+
>
);