diff --git a/.gitignore b/.gitignore
index 16d803b56..fbc6e8450 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,8 @@ db.sqlite3
# Cursor rules
.cursorrules
+
+# Claude
+CLAUDE.md
+.claude/
+openspec/
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 ad781879c..a827ebad8 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
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg';
+import CoPresentSVG from '@/assets/icons/ui-kit/co_present.svg';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
import DownloadSVG from '@/assets/icons/ui-kit/download.svg';
@@ -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: ,
@@ -320,6 +339,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
+ {isPresenterOpen && (
+ {
+ setIsPresenterOpen(false);
+ restoreFocus();
+ }}
+ />
+ )}
);
};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterOverlay.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterOverlay.spec.tsx
new file mode 100644
index 000000000..0bfea2220
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/PresenterOverlay.spec.tsx
@@ -0,0 +1,141 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ test,
+ vi,
+} from 'vitest';
+
+import { AppWrapper } from '@/tests/utils';
+
+const requestFullscreen = vi.fn(async () => {});
+const exitFullscreen = vi.fn(async () => {});
+
+vi.mock('@/docs/doc-editor/components/BlockNoteEditor', () => ({
+ blockNoteSchema: {},
+}));
+
+vi.mock('@/docs/doc-editor/styles', () => ({
+ cssEditor: '',
+}));
+
+vi.mock('@blocknote/mantine', () => ({
+ BlockNoteView: ({ editor: _editor }: { editor: unknown }) => (
+
+ ),
+}));
+
+vi.mock('@blocknote/react', () => ({
+ useCreateBlockNote: () => ({}),
+}));
+
+const editorDocument = [
+ { type: 'heading', content: [{ type: 'text', text: 'Slide 1' }] },
+ { type: 'divider' },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Slide 2 body' }] },
+ { type: 'divider' },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Slide 3 body' }] },
+ { type: 'divider' },
+ // Empty group between two dividers — must be dropped.
+ { type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
+];
+
+vi.mock('@/docs/doc-editor/stores', () => ({
+ useEditorStore: (selector: (s: unknown) => unknown) =>
+ selector({ editor: { document: editorDocument } }),
+}));
+
+import { PresenterOverlay } from '../components/PresenterOverlay';
+
+describe('PresenterOverlay', () => {
+ beforeEach(() => {
+ Object.defineProperty(document, 'fullscreenElement', {
+ configurable: true,
+ get: () => null,
+ });
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ });
+ Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ value: exitFullscreen,
+ });
+ requestFullscreen.mockClear();
+ exitFullscreen.mockClear();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const doc = { id: 'd1', deleted_at: null } as never;
+
+ test('renders 3 slides (empty group dropped) and starts at slide 1/3', () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('1 / 3')).toBeInTheDocument();
+ });
+
+ test('ArrowRight navigates to the next slide', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.keyDown(window, { code: 'ArrowRight' });
+ expect(screen.getByText('2 / 3')).toBeInTheDocument();
+ });
+
+ test('clicking close invokes onClose', () => {
+ const onClose = vi.fn();
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Close presenter' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ test('next is disabled on the last slide', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.keyDown(window, { code: 'End' });
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ expect(
+ (screen.getByRole('button', { name: 'Next slide' }) as HTMLButtonElement)
+ .disabled,
+ ).toBe(true);
+ });
+
+ test('mounting does NOT auto-enter fullscreen', () => {
+ render(
+
+
+ ,
+ );
+ expect(requestFullscreen).not.toHaveBeenCalled();
+ });
+
+ test('clicking the fullscreen toggle calls requestFullscreen on documentElement', () => {
+ render(
+
+
+ ,
+ );
+ requestFullscreen.mockClear();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Enter fullscreen' }),
+ );
+ expect(requestFullscreen).toHaveBeenCalled();
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts
new file mode 100644
index 000000000..1c1a07ddd
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts
@@ -0,0 +1,94 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
+
+describe('useBrowserFullscreen', () => {
+ let fullscreenElement: Element | null = null;
+ const requestFullscreen = vi.fn(async () => {
+ fullscreenElement = document.documentElement;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+ const exitFullscreen = vi.fn(async () => {
+ fullscreenElement = null;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+
+ beforeEach(() => {
+ fullscreenElement = null;
+ Object.defineProperty(document, 'fullscreenElement', {
+ configurable: true,
+ get: () => fullscreenElement,
+ });
+ Object.defineProperty(document.documentElement, 'requestFullscreen', {
+ configurable: true,
+ value: requestFullscreen,
+ });
+ Object.defineProperty(document, 'exitFullscreen', {
+ configurable: true,
+ value: exitFullscreen,
+ });
+ requestFullscreen.mockClear();
+ exitFullscreen.mockClear();
+ });
+
+ afterEach(() => {
+ fullscreenElement = null;
+ });
+
+ test('initial state reflects current fullscreen state', () => {
+ const { result } = renderHook(() => useBrowserFullscreen());
+ expect(result.current.isFullscreen).toBe(false);
+ });
+
+ test('enter() requests fullscreen and updates state', async () => {
+ const { result } = renderHook(() => useBrowserFullscreen());
+ await act(async () => {
+ await result.current.enter();
+ });
+ expect(requestFullscreen).toHaveBeenCalledTimes(1);
+ expect(result.current.isFullscreen).toBe(true);
+ });
+
+ test('enter() is a no-op if already fullscreen', async () => {
+ fullscreenElement = document.documentElement;
+ const { result } = renderHook(() => useBrowserFullscreen());
+ await act(async () => {
+ await result.current.enter();
+ });
+ expect(requestFullscreen).not.toHaveBeenCalled();
+ });
+
+ test('exit() leaves fullscreen and updates state', async () => {
+ const { result } = renderHook(() => useBrowserFullscreen());
+ await act(async () => {
+ await result.current.enter();
+ });
+ await act(async () => {
+ await result.current.exit();
+ });
+ expect(exitFullscreen).toHaveBeenCalledTimes(1);
+ expect(result.current.isFullscreen).toBe(false);
+ });
+
+ test('toggle() flips state', async () => {
+ const { result } = renderHook(() => useBrowserFullscreen());
+ await act(async () => {
+ await result.current.toggle();
+ });
+ expect(result.current.isFullscreen).toBe(true);
+ await act(async () => {
+ await result.current.toggle();
+ });
+ expect(result.current.isFullscreen).toBe(false);
+ });
+
+ test('reacts to external fullscreenchange events', () => {
+ const { result } = renderHook(() => useBrowserFullscreen());
+ act(() => {
+ fullscreenElement = document.documentElement;
+ document.dispatchEvent(new Event('fullscreenchange'));
+ });
+ expect(result.current.isFullscreen).toBe(true);
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts
new file mode 100644
index 000000000..c6011844b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts
@@ -0,0 +1,94 @@
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
+
+const renderShortcuts = (
+ overrides: Partial[0]> = {},
+) => {
+ const handlers = {
+ onPrev: vi.fn(),
+ onNext: vi.fn(),
+ onFirst: vi.fn(),
+ onLast: vi.fn(),
+ onToggleFullscreen: vi.fn(),
+ onClose: vi.fn(),
+ isFullscreen: false,
+ ...overrides,
+ };
+ renderHook(() => usePresenterShortcuts(handlers));
+ return handlers;
+};
+
+const press = (init: KeyboardEventInit) => {
+ const event = new KeyboardEvent('keydown', { ...init, cancelable: true });
+ window.dispatchEvent(event);
+ return event;
+};
+
+describe('usePresenterShortcuts', () => {
+ test('ArrowLeft and PageUp call onPrev', () => {
+ const h = renderShortcuts();
+ press({ code: 'ArrowLeft' });
+ press({ code: 'PageUp' });
+ expect(h.onPrev).toHaveBeenCalledTimes(2);
+ });
+
+ test('ArrowRight, PageDown and Space call onNext', () => {
+ const h = renderShortcuts();
+ press({ code: 'ArrowRight' });
+ press({ code: 'PageDown' });
+ press({ code: 'Space' });
+ expect(h.onNext).toHaveBeenCalledTimes(3);
+ });
+
+ test('Home calls onFirst, End calls onLast', () => {
+ const h = renderShortcuts();
+ press({ code: 'Home' });
+ press({ code: 'End' });
+ expect(h.onFirst).toHaveBeenCalledTimes(1);
+ expect(h.onLast).toHaveBeenCalledTimes(1);
+ });
+
+ test('KeyF toggles fullscreen but ignores modifiers', () => {
+ const h = renderShortcuts();
+ press({ code: 'KeyF' });
+ press({ code: 'KeyF', metaKey: true });
+ press({ code: 'KeyF', ctrlKey: true });
+ expect(h.onToggleFullscreen).toHaveBeenCalledTimes(1);
+ });
+
+ test('Escape calls onClose only when not fullscreen', () => {
+ const h1 = renderShortcuts({ isFullscreen: false });
+ press({ code: 'Escape' });
+ expect(h1.onClose).toHaveBeenCalledTimes(1);
+
+ const h2 = renderShortcuts({ isFullscreen: true });
+ press({ code: 'Escape' });
+ expect(h2.onClose).not.toHaveBeenCalled();
+ });
+
+ test('Space prevents default to avoid page scroll', () => {
+ renderShortcuts();
+ const event = press({ code: 'Space' });
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ test('Arrow keys prevent default', () => {
+ renderShortcuts();
+ expect(press({ code: 'ArrowLeft' }).defaultPrevented).toBe(true);
+ expect(press({ code: 'ArrowRight' }).defaultPrevented).toBe(true);
+ });
+
+ test('non-arrow repeat events are ignored', () => {
+ const h = renderShortcuts();
+ press({ code: 'Space', repeat: true });
+ expect(h.onNext).not.toHaveBeenCalled();
+ });
+
+ test('arrow repeat events are accepted', () => {
+ const h = renderShortcuts();
+ press({ code: 'ArrowRight', repeat: true });
+ expect(h.onNext).toHaveBeenCalledTimes(1);
+ });
+});
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
new file mode 100644
index 000000000..0c21fea0b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts
@@ -0,0 +1,128 @@
+import { describe, expect, test } from 'vitest';
+
+import { isEmptyBlock, splitBlocksIntoSlides } from '../hooks/useSlides';
+
+const para = (text = 'hello') => ({
+ type: 'paragraph',
+ content: text === '' ? [] : [{ type: 'text', text }],
+});
+const heading = (text = 'Title', level = 1) => ({
+ type: 'heading',
+ content: [{ type: 'text', text }],
+ props: { level },
+});
+const divider = () => ({ type: 'divider' });
+const image = () => ({ type: 'image', props: { url: 'x' } });
+
+describe('isEmptyBlock', () => {
+ test('empty paragraph (no content array entries) is empty', () => {
+ expect(isEmptyBlock(para(''))).toBe(true);
+ });
+
+ test('whitespace-only paragraph is empty', () => {
+ expect(isEmptyBlock(para(' '))).toBe(true);
+ });
+
+ test('paragraph with text is not empty', () => {
+ expect(isEmptyBlock(para('hi'))).toBe(false);
+ });
+
+ test('heading with whitespace is empty', () => {
+ expect(isEmptyBlock(heading(' '))).toBe(true);
+ });
+
+ test('image is never empty', () => {
+ expect(isEmptyBlock(image() as any)).toBe(false);
+ });
+
+ test('divider is not "empty" (it is filtered separately)', () => {
+ expect(isEmptyBlock(divider() as any)).toBe(false);
+ });
+
+ test('block with children is not empty', () => {
+ const b = { type: 'paragraph', content: [], children: [para()] };
+ expect(isEmptyBlock(b as any)).toBe(false);
+ });
+});
+
+describe('splitBlocksIntoSlides', () => {
+ test('no divider yields one slide', () => {
+ const result = splitBlocksIntoSlides([para('a'), para('b')]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(2);
+ });
+
+ test('one divider yields two slides', () => {
+ const result = splitBlocksIntoSlides([para('a'), divider(), para('b')]);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toHaveLength(1);
+ expect(result[1]).toHaveLength(1);
+ });
+
+ test('leading divider does not produce an empty slide', () => {
+ const result = splitBlocksIntoSlides([divider(), para('a')]);
+ expect(result).toHaveLength(1);
+ });
+
+ test('trailing divider does not produce an empty slide', () => {
+ const result = splitBlocksIntoSlides([para('a'), divider()]);
+ expect(result).toHaveLength(1);
+ });
+
+ test('consecutive dividers do not produce empty slides', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ divider(),
+ divider(),
+ divider(),
+ para('b'),
+ ]);
+ expect(result).toHaveLength(2);
+ });
+
+ test('empty doc yields one empty slide', () => {
+ const result = splitBlocksIntoSlides([]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(0);
+ });
+
+ test('divider-only doc yields one empty slide', () => {
+ const result = splitBlocksIntoSlides([divider(), divider()]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(0);
+ });
+
+ test('group of only empty paragraphs is dropped', () => {
+ const result = splitBlocksIntoSlides([
+ para('a'),
+ divider(),
+ para(''),
+ para(' '),
+ divider(),
+ para('b'),
+ ]);
+ expect(result).toHaveLength(2);
+ expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
+ expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
+ });
+
+ test('group with one empty + one non-empty paragraph keeps only the non-empty', () => {
+ const result = splitBlocksIntoSlides([para(''), para('hi'), para(' ')]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(1);
+ expect(result[0][0]).toMatchObject({ content: [{ text: 'hi' }] });
+ });
+
+ test('image-only group is kept', () => {
+ const result = splitBlocksIntoSlides([para('a'), divider(), image()]);
+ expect(result).toHaveLength(2);
+ expect(result[1]).toHaveLength(1);
+ });
+
+ test('heading with whitespace is filtered', () => {
+ const result = splitBlocksIntoSlides([heading(' '), para('body')]);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(1);
+ expect(result[0][0]).toMatchObject({ type: 'paragraph' });
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
new file mode 100644
index 000000000..ee2b9bf12
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
@@ -0,0 +1,116 @@
+import { Button } from '@gouvfr-lasuite/cunningham-react';
+import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
+
+import { Box, Icon, Text } from '@/components';
+
+interface PresenterFloatingBarProps {
+ index: number;
+ total: number;
+ isFullscreen: boolean;
+ onPrev: () => void;
+ onNext: () => void;
+ onToggleFullscreen: () => void;
+ onClose: () => void;
+}
+
+const barCss = css`
+ position: fixed;
+ bottom: 1.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1;
+ flex-direction: row !important;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ background: white;
+ border: 1px solid var(--c--theme--colors--greyscale-200, #e5e5e5);
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+`;
+
+const separatorCss = css`
+ width: 1px;
+ height: 1.25rem;
+ background: var(--c--theme--colors--greyscale-200, #e5e5e5);
+ margin: 0 0.25rem;
+`;
+
+export const PresenterFloatingBar = ({
+ index,
+ total,
+ isFullscreen,
+ onPrev,
+ onNext,
+ onToggleFullscreen,
+ onClose,
+}: PresenterFloatingBarProps) => {
+ const { t } = useTranslation();
+ const isFirst = index <= 0;
+ const isLast = index >= total - 1;
+
+ return (
+
+ }
+ />
+
+ {index + 1} / {total}
+
+ }
+ />
+
+
+ }
+ />
+ }
+ />
+
+ );
+};
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
new file mode 100644
index 000000000..32f03d54c
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
+
+import { Box, Text } from '@/components';
+import { useEditorStore } from '@/docs/doc-editor/stores';
+import { Doc } from '@/docs/doc-management';
+
+import { PRESENTER_WINDOW_RADIUS } from '../constants';
+import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
+import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
+import { useSlides } from '../hooks/useSlides';
+
+import { PresenterFloatingBar } from './PresenterFloatingBar';
+import { PresenterSlide } from './PresenterSlide';
+
+interface PresenterOverlayProps {
+ doc: Doc;
+ onClose: () => void;
+}
+
+const overlayCss = css`
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: #f2f3f5;
+ display: flex;
+ flex-direction: column;
+`;
+
+const labelCss = css`
+ position: fixed;
+ top: 0.75rem;
+ left: 1rem;
+ color: var(--c--theme--colors--greyscale-500, #8a8a8a);
+ font-size: 0.8125rem;
+ pointer-events: none;
+`;
+
+const slideAreaCss = css`
+ flex: 1;
+ /* Plain block layout — flex centering breaks vertical scrolling
+ for content taller than the container. */
+ display: block;
+ overflow: hidden;
+ padding: 2rem 2rem 6rem;
+`;
+
+const slideFrameCss = css`
+ width: 100%;
+ height: 100%;
+ margin: 0 auto;
+ background: white;
+ border-radius: 0.25rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ /* The slide frame is the vertical scroll container. */
+ overflow-y: auto;
+ overflow-x: hidden;
+`;
+
+const slideWrapperCss = css`
+ width: 100%;
+ /* min-height ensures short slides still fill the frame so the editor
+ can compute its 100%-height layout, but tall slides expand and scroll. */
+ min-height: 100%;
+ padding: 3rem 0;
+ box-sizing: border-box;
+`;
+
+export const PresenterOverlay = ({
+ doc: _doc,
+ onClose,
+}: PresenterOverlayProps) => {
+ const { t } = useTranslation();
+ const editor = useEditorStore((state) => state.editor);
+
+ // Snapshot the editor's blocks once at mount. Subsequent collaborator
+ // edits do not affect the ongoing presentation (by design).
+ 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 goPrev = useCallback(
+ () => setCurrentIndex((i) => clamp(i - 1)),
+ [clamp],
+ );
+ const goNext = useCallback(
+ () => setCurrentIndex((i) => clamp(i + 1)),
+ [clamp],
+ );
+ const goFirst = useCallback(() => setCurrentIndex(0), []);
+ const goLast = useCallback(
+ () => setCurrentIndex(clamp(total - 1)),
+ [clamp, total],
+ );
+
+ const { isFullscreen, exit, toggle } = useBrowserFullscreen();
+
+ // Leave fullscreen on unmount if the user entered it via the bar.
+ useEffect(() => {
+ return () => {
+ void exit();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ usePresenterShortcuts({
+ onPrev: goPrev,
+ onNext: goNext,
+ onFirst: goFirst,
+ onLast: goLast,
+ onToggleFullscreen: () => void toggle(),
+ onClose,
+ isFullscreen,
+ });
+
+ 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;
+ }, [currentIndex, total]);
+
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ return createPortal(
+
+
+ {t('Docs - Presenter mode')}
+
+
+
+
+ {mountedIndices.map((i) => (
+
+
+
+ ))}
+
+
+
+ void toggle()}
+ onClose={onClose}
+ />
+ ,
+ document.body,
+ );
+};
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
new file mode 100644
index 000000000..047d0a0b8
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
@@ -0,0 +1,58 @@
+import { BlockNoteView } from '@blocknote/mantine';
+import { useCreateBlockNote } from '@blocknote/react';
+import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
+
+import { Box } from '@/components';
+import { blockNoteSchema } from '@/docs/doc-editor/components/BlockNoteEditor';
+import { cssEditor } from '@/docs/doc-editor/styles';
+
+import { PRESENTER_SLIDE_MAX_WIDTH } from '../constants';
+
+interface PresenterSlideProps {
+ blocks: unknown[];
+ ariaLabel?: string;
+}
+
+const slideCss = css`
+ ${cssEditor};
+ width: 100%;
+ max-width: ${PRESENTER_SLIDE_MAX_WIDTH}px;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+ /* Hide editor chrome that may leak through despite editable={false} */
+ .bn-side-menu,
+ .bn-formatting-toolbar,
+ .bn-slash-menu {
+ display: none !important;
+ }
+`;
+
+export const PresenterSlide = ({ blocks, ariaLabel }: PresenterSlideProps) => {
+ const { t } = useTranslation();
+ const editor = useCreateBlockNote({
+ initialContent:
+ // BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
+ blocks.length > 0
+ ? (blocks as NonNullable[0]>['initialContent'])
+ : undefined,
+ schema: blockNoteSchema,
+ });
+
+ return (
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts
new file mode 100644
index 000000000..567ad92d5
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts
@@ -0,0 +1,9 @@
+/**
+ * Half-window of slide renderers mounted around the current slide.
+ * Total mounted = 2 * PRESENTER_WINDOW_RADIUS + 1.
+ * 1 = three slides mounted (prev, current, next) — sweet spot between
+ * memory and navigation flash. Tune freely.
+ */
+export const PRESENTER_WINDOW_RADIUS = 1;
+
+export const PRESENTER_SLIDE_MAX_WIDTH = 868;
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useBrowserFullscreen.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useBrowserFullscreen.ts
new file mode 100644
index 000000000..01b9ada09
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useBrowserFullscreen.ts
@@ -0,0 +1,67 @@
+import { useCallback, useEffect, useState } from 'react';
+
+const isCurrentlyFullscreen = () =>
+ typeof document !== 'undefined' && !!document.fullscreenElement;
+
+export const useBrowserFullscreen = () => {
+ const [isFullscreen, setIsFullscreen] = useState(
+ isCurrentlyFullscreen,
+ );
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ const handleChange = () => setIsFullscreen(isCurrentlyFullscreen());
+ document.addEventListener('fullscreenchange', handleChange);
+ return () => {
+ document.removeEventListener('fullscreenchange', handleChange);
+ };
+ }, []);
+
+ const enter = useCallback(async () => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ if (isCurrentlyFullscreen()) {
+ return;
+ }
+ if (!document.documentElement.requestFullscreen) {
+ return;
+ }
+ try {
+ await document.documentElement.requestFullscreen();
+ } catch {
+ // Browsers reject the request when not triggered by a user gesture
+ // or when the API is unavailable. The presenter remains usable
+ // without fullscreen, so we swallow the rejection silently.
+ }
+ }, []);
+
+ const exit = useCallback(async () => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ if (!isCurrentlyFullscreen()) {
+ return;
+ }
+ if (!document.exitFullscreen) {
+ return;
+ }
+ try {
+ await document.exitFullscreen();
+ } catch {
+ // Ignore: nothing actionable if exit fails.
+ }
+ }, []);
+
+ const toggle = useCallback(async () => {
+ if (isCurrentlyFullscreen()) {
+ await exit();
+ } else {
+ await enter();
+ }
+ }, [enter, exit]);
+
+ return { isFullscreen, enter, exit, toggle };
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterShortcuts.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterShortcuts.ts
new file mode 100644
index 000000000..c0a2e5bf9
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterShortcuts.ts
@@ -0,0 +1,82 @@
+import { useEffect } from 'react';
+
+interface ShortcutHandlers {
+ onPrev: () => void;
+ onNext: () => void;
+ onFirst: () => void;
+ onLast: () => void;
+ onToggleFullscreen: () => void;
+ onClose: () => void;
+ isFullscreen: boolean;
+}
+
+const ARROW_CODES = new Set(['ArrowLeft', 'ArrowRight']);
+
+export const usePresenterShortcuts = ({
+ onPrev,
+ onNext,
+ onFirst,
+ onLast,
+ onToggleFullscreen,
+ onClose,
+ isFullscreen,
+}: ShortcutHandlers) => {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.repeat && !ARROW_CODES.has(event.code)) {
+ return;
+ }
+
+ switch (event.code) {
+ case 'ArrowLeft':
+ case 'PageUp':
+ event.preventDefault();
+ onPrev();
+ return;
+ case 'ArrowRight':
+ case 'PageDown':
+ case 'Space':
+ event.preventDefault();
+ onNext();
+ return;
+ case 'Home':
+ event.preventDefault();
+ onFirst();
+ return;
+ case 'End':
+ event.preventDefault();
+ onLast();
+ return;
+ case 'KeyF':
+ if (event.ctrlKey || event.metaKey || event.altKey) {
+ return;
+ }
+ event.preventDefault();
+ onToggleFullscreen();
+ return;
+ case 'Escape':
+ // While fullscreen, the browser handles Esc natively (exits
+ // fullscreen) and we deliberately stay open. Once out of
+ // fullscreen, Esc closes the presenter.
+ if (!isFullscreen) {
+ event.preventDefault();
+ onClose();
+ }
+ return;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [
+ onPrev,
+ onNext,
+ onFirst,
+ onLast,
+ onToggleFullscreen,
+ onClose,
+ isFullscreen,
+ ]);
+};
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
new file mode 100644
index 000000000..475893971
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts
@@ -0,0 +1,81 @@
+import { useMemo } from 'react';
+
+type Block = {
+ type: string;
+ content?: unknown;
+ children?: Block[];
+};
+
+const TEXT_BEARING_TYPES = new Set([
+ 'paragraph',
+ 'heading',
+ 'bulletListItem',
+ 'numberedListItem',
+ 'checkListItem',
+ 'quote',
+]);
+
+const extractText = (content: unknown): string => {
+ if (!content) {
+ return '';
+ }
+ if (typeof content === 'string') {
+ return content;
+ }
+ if (Array.isArray(content)) {
+ return content.map(extractText).join('');
+ }
+ if (typeof content === 'object') {
+ const obj = content as Record;
+ if (typeof obj.text === 'string') {
+ return obj.text;
+ }
+ if ('content' in obj) {
+ return extractText(obj.content);
+ }
+ }
+ return '';
+};
+
+export const isEmptyBlock = (block: Block): boolean => {
+ if (!TEXT_BEARING_TYPES.has(block.type)) {
+ return false;
+ }
+ if (block.children && block.children.length > 0) {
+ return false;
+ }
+ return extractText(block.content).trim() === '';
+};
+
+/**
+ * Split a flat list of top-level blocks into slide groups.
+ *
+ * - Each `divider` block separates two slides; the divider itself is dropped.
+ * - Empty text-bearing blocks (paragraph, heading, ...) are filtered out.
+ * - Groups that are empty after filtering are removed entirely.
+ * - 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[] = [];
+
+ for (const block of blocks) {
+ if (block.type === 'divider') {
+ groups.push(current);
+ current = [];
+ continue;
+ }
+ current.push(block);
+ }
+ groups.push(current);
+
+ const cleaned = groups
+ .map((group) => group.filter((b) => !isEmptyBlock(b)))
+ .filter((group) => group.length > 0);
+
+ return cleaned.length > 0 ? cleaned : [[]];
+};
+
+export const useSlides = (blocks: T[]): T[][] => {
+ return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
+};
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
new file mode 100644
index 000000000..0ad4b13a3
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/index.ts
@@ -0,0 +1 @@
+export { PresenterOverlay } from './components/PresenterOverlay';