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 19973d6c2..d6454d5bc 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,5 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
-import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
+import { Present, useTreeContext } from '@gouvfr-lasuite/ui-kit';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -79,6 +79,14 @@ const ModalExport =
)
: null;
+const PresenterOverlay = dynamic(
+ () =>
+ import('@/docs/doc-presenter').then((mod) => ({
+ default: mod.PresenterOverlay,
+ })),
+ { ssr: false },
+);
+
interface DocToolBoxProps {
doc: Doc;
}
@@ -93,6 +101,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 +185,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 +338,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
+ {isPresenterOpen && (
+ {
+ setIsPresenterOpen(false);
+ restoreFocus();
+ }}
+ />
+ )}
);
};
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..be37d953f
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
@@ -0,0 +1,118 @@
+import { Button } from '@gouvfr-lasuite/cunningham-react';
+import {
+ ChevronLeft,
+ ChevronRight,
+ Maximize,
+ XMark,
+} from '@gouvfr-lasuite/ui-kit';
+import { useTranslation } from 'react-i18next';
+import { css } from 'styled-components';
+
+import { Box, 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: var(--c--globals--spacings--3xs, 4px);
+ border-radius: 8px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ color: var(--c--contextuals--content--semantic--neutral--secondary);
+ border: 1px solid var(--c--contextuals--border--surface--primary);
+ background: var(--c--contextuals--background--surface--primary);
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
+`;
+
+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..9af30835b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
@@ -0,0 +1,188 @@
+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 } from '@/components';
+import { useEditorStore } from '@/docs/doc-editor/stores';
+import { Doc } from '@/docs/doc-management';
+import { useFocusStore } from '@/stores';
+
+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: white;
+ display: flex;
+ flex-direction: column;
+`;
+
+const slideAreaCss = css`
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+`;
+
+const slideFrameCss = css`
+ width: min(80%, 1800px);
+ height: 100%;
+ background: white;
+ overflow-y: auto;
+ overflow-x: hidden;
+ justify-content: center;
+ align-items: center;
+
+ @media (max-width: 1000px) {
+ width: 95%;
+ }
+`;
+
+const slideWrapperCss = css`
+ width: 100%;
+ max-height: 100%;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export const PresenterOverlay = ({
+ doc: _doc,
+ onClose,
+}: PresenterOverlayProps) => {
+ const { t } = useTranslation();
+ const editor = useEditorStore((state) => state.editor);
+ const { addLastFocus } = useFocusStore();
+
+ // 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;
+
+ // The presenter is opened from a dropdown menu item which doesn't expose
+ // its trigger to the click handler — so we capture the previously focused
+ // element here, on mount, after the dropdown has restored focus to its
+ // trigger button. `restoreFocus()` is then called by the parent on close.
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ addLastFocus(document.activeElement as HTMLElement | null);
+ }, [addLastFocus]);
+
+ 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, enter, exitIfOwned, toggle } = useBrowserFullscreen();
+
+ useEffect(() => {
+ void enter();
+ return () => {
+ void exitIfOwned();
+ };
+ }, [enter, exitIfOwned]);
+
+ 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(
+
+
+
+ {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..5a66add36
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
@@ -0,0 +1,63 @@
+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';
+
+interface PresenterSlideProps {
+ blocks: unknown[];
+ ariaLabel?: string;
+}
+
+const slideCss = css`
+ ${cssEditor};
+ width: fit-content;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+ /* Scale the slide content so it reads larger than the regular editor.
+ * Using \`zoom\` (not \`transform: scale\`) so parent scroll, hit testing
+ * and overflow detection account for the upscaled size. */
+ zoom: 1.3;
+ /* 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<
+ Parameters[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..508aa025e
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts
@@ -0,0 +1,7 @@
+/**
+ * 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;
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..213e94288
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useBrowserFullscreen.ts
@@ -0,0 +1,89 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+const isCurrentlyFullscreen = () =>
+ typeof document !== 'undefined' && !!document.fullscreenElement;
+
+export const useBrowserFullscreen = () => {
+ const [isFullscreen, setIsFullscreen] = useState(
+ isCurrentlyFullscreen,
+ );
+ // Tracks whether the *current* fullscreen session was started by us.
+ // Prevents tearing down a fullscreen the user (or OS) had already
+ // entered before this hook was mounted.
+ const ownedRef = useRef(false);
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ const handleChange = () => {
+ const fs = isCurrentlyFullscreen();
+ // Anytime fullscreen ends — Esc, our exit(), OS — release ownership.
+ if (!fs) {
+ ownedRef.current = false;
+ }
+ setIsFullscreen(fs);
+ };
+ 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();
+ ownedRef.current = true;
+ } 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.
+ }
+ }, []);
+
+ // Same as exit() but bails out if we didn't initiate the fullscreen.
+ // Use this for cleanup-on-unmount so we don't yank a user out of a
+ // session they opened themselves before the presenter mounted.
+ const exitIfOwned = useCallback(async () => {
+ if (!ownedRef.current) {
+ return;
+ }
+ await exit();
+ }, [exit]);
+
+ const toggle = useCallback(async () => {
+ if (isCurrentlyFullscreen()) {
+ await exit();
+ } else {
+ await enter();
+ }
+ }, [enter, exit]);
+
+ return { isFullscreen, enter, exit, exitIfOwned, 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..02ef7cc76
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterShortcuts.ts
@@ -0,0 +1,99 @@
+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 'Space': {
+ // A focused button activates on `keyup` (native click). If we
+ // also call onNext() here on `keydown`, Space on the toolbar's
+ // Next button fires twice. Skip when the event target handles
+ // Space natively.
+ const target = event.target;
+ if (
+ target instanceof Element &&
+ target.closest(
+ 'button, [role="button"], a, input, textarea, select, [contenteditable="true"]',
+ )
+ ) {
+ return;
+ }
+ event.preventDefault();
+ onNext();
+ return;
+ }
+ case 'ArrowRight':
+ case 'PageDown':
+ 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';