diff --git a/CHANGELOG.md b/CHANGELOG.md
index eace8fb08..479013943 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to
- 🔧(backend) allow configuring settings OIDC_OP_USER_ENDPOINT_FORMAT
- ⚡️(helm) create a dedicated svc and deployment for yprovider converter #2368
- ✨(backend) allow to leave a document #2365
+- ✨(frontend) add the presenter mode
- 📈(backend) create a utils to capture event with posthog
### Changed
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index aa29bfe4e..edbf66992 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -42,7 +42,7 @@
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.3.0",
"@gouvfr-lasuite/integration": "1.0.3",
- "@gouvfr-lasuite/ui-kit": "0.23.1",
+ "@gouvfr-lasuite/ui-kit": "0.23.2",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "9.2.1",
"@mantine/hooks": "9.2.1",
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 78380ca3d..7e7771e87 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,9 +1,10 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import {
DropdownMenu,
- DropdownMenuOption,
+ DropdownMenuItem,
useTreeContext,
} from '@gouvfr-lasuite/ui-kit';
+import { Present } from '@gouvfr-lasuite/ui-kit/icons';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -70,6 +71,14 @@ const ModalExport =
)
: null;
+const PresenterOverlay = dynamic(
+ () =>
+ import('@/docs/doc-presenter').then((mod) => ({
+ default: mod.PresenterOverlay,
+ })),
+ { ssr: false },
+);
+
interface DocToolBoxProps {
doc: Doc;
}
@@ -81,11 +90,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { isTopRoot } = useDocUtils(doc);
const { authenticated } = useAuth();
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
-
const [openDropdown, setOpenDropdown] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const shareModal = useModal();
+ const [isPresenterOpen, setIsPresenterOpen] = useState(false);
const selectHistoryModal = useModal();
const { restoreFocus } = useFocusStore();
@@ -103,7 +112,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
- const options: DropdownMenuOption[] = [
+ const options: DropdownMenuItem[] = [
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: doc.is_favorite ? (
@@ -120,7 +129,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
isHidden: !doc.abilities.favorite,
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
- showSeparator: true,
+ },
+ { type: 'separator' },
+ {
+ label: t('Present'),
+ icon: ,
+ callback: () => {
+ setIsPresenterOpen(true);
+ },
+ isHidden: Boolean(doc.deleted_at) || isMobile,
+ testId: `docs-actions-present-${doc.id}`,
},
{
label: t('Copy link'),
@@ -260,6 +278,16 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
isRootDoc={treeContext?.root?.id === doc.id}
/>
)}
+
+ {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..9aab57f53
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterFloatingBar.tsx
@@ -0,0 +1,140 @@
+import { Button } from '@gouvfr-lasuite/cunningham-react';
+import {
+ ChevronLeft,
+ ChevronRight,
+ Maximize,
+ Minimize,
+ XMark,
+} from '@gouvfr-lasuite/ui-kit/icons';
+import { useEffect, useRef } from 'react';
+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;
+
+ // Move focus into the dialog on open so keyboard users land on the
+ // controls (an ARIA dialog must move focus inside itself; here on the
+ // first enabled toolbar button — "Next", since "Previous" is disabled on
+ // the first slide). The rAF wins the race against the dropdown's async
+ // focus restoration — same pattern as ModalRemoveDoc.
+ const barRef = useRef(null);
+ useEffect(() => {
+ const id = requestAnimationFrame(() => {
+ barRef.current
+ ?.querySelector('button:not([disabled])')
+ ?.focus();
+ });
+ return () => cancelAnimationFrame(id);
+ }, []);
+
+ return (
+
+ }
+ />
+
+ {index + 1} / {total}
+
+
+ {t('Slide {{current}} of {{total}}', {
+ current: 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..1018691cf
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterOverlay.tsx
@@ -0,0 +1,146 @@
+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 { 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;
+ position: relative;
+ overflow: hidden;
+ background: white;
+`;
+
+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, 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]);
+
+ const frameRef = useRef(null);
+
+ 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..f52fb0bac
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterSlide.tsx
@@ -0,0 +1,139 @@
+import { BlockNoteView } from '@blocknote/mantine';
+import { useCreateBlockNote } from '@blocknote/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,
+ PRESENTER_SLIDE_DESIGN_WIDTH,
+ PRESENTER_SLIDE_FADE_MS,
+} from '../constants';
+import { useFitScale } from '../hooks/useFitScale';
+
+interface PresenterSlideProps {
+ blocks: unknown[];
+ frameRef: RefObject;
+ isCurrent: boolean;
+ ariaLabel?: string;
+}
+
+// Outer is the scroll container. It always spans the full frame height so the
+// content can scroll all the way to the visual top/bottom of the viewport when
+// the user drags the scrollbar. The top/bottom padding provides the breathing
+// room at rest (scrollTop = 0 shows the content offset by paddingY); when the
+// user scrolls, the padding scrolls off with the content (classic scroll-padding
+// behaviour you get for free from padding on a scroll container).
+//
+// We use display: flex + margin: auto on the stage to centre vertically when
+// the content fits, while still allowing the stage to overflow (and scroll)
+// when it doesn't fit — a well-known CSS idiom (`align-items: center` would
+// instead clip the overflowing top).
+const outerCss = css`
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ height: 100%;
+ box-sizing: border-box;
+ padding-top: ${PRESENTER_FRAME_PADDING_Y}px;
+ padding-bottom: ${PRESENTER_FRAME_PADDING_Y}px;
+ overflow-y: auto;
+ 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
+// matches the painted height of the scaled inner (`naturalH × scale`), so
+// the scroll container sees a `scrollHeight` aligned with what the user
+// actually sees — not the un-transformed layout box.
+// `margin: auto` centres the stage vertically when it fits the outer's
+// content area, and collapses to 0 when it doesn't (enabling natural
+// top-anchored scroll).
+const stageCss = css`
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+ margin: auto;
+ flex-shrink: 0;
+`;
+
+const innerCss = css`
+ display: block;
+ width: ${PRESENTER_SLIDE_DESIGN_WIDTH}px;
+ transform-origin: left top;
+`;
+
+export const PresenterSlide = ({
+ blocks,
+ frameRef,
+ isCurrent,
+ 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,
+ });
+
+ const fit = useFitScale(innerRef, frameRef);
+
+ const outerStyle: React.CSSProperties = {
+ width: fit ? `${fit.outerWidth}px` : undefined,
+ opacity: fit && isCurrent ? 1 : 0,
+ visibility: isCurrent ? 'visible' : 'hidden',
+ pointerEvents: isCurrent ? 'auto' : 'none',
+ };
+
+ const stageStyle: React.CSSProperties = {
+ height: fit ? `${fit.stageHeight}px` : undefined,
+ };
+
+ const innerStyle: React.CSSProperties = {
+ transform: fit ? `scale(${fit.scale})` : 'none',
+ };
+
+ 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..7f882945e
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/constants.ts
@@ -0,0 +1,44 @@
+/**
+ * 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;
+
+/**
+ * Intrinsic design width of slide content, in CSS pixels. The slide's
+ * inner wrapper renders at this exact width then `transform: scale(...)`
+ * fits it into the available viewport.
+ */
+export const PRESENTER_SLIDE_DESIGN_WIDTH = 900;
+
+/**
+ * Lower bound of the per-slide scale. Below this, vertical scroll kicks
+ * in instead of further shrinking the content.
+ */
+export const PRESENTER_SLIDE_MIN_SCALE = 0.7;
+
+/**
+ * Upper bound of the per-slide scale. Prevents sparse slides from
+ * ballooning to an unreadable cinematic size.
+ */
+export const PRESENTER_SLIDE_MAX_SCALE = 1.5;
+
+/**
+ * Horizontal breathing room around the active slide, subtracted from
+ * the frame's clientWidth before computing the width-based scale.
+ */
+export const PRESENTER_FRAME_PADDING_X = 64;
+
+/**
+ * Vertical breathing room around the active slide. Also leaves clearance
+ * for the floating action bar.
+ */
+export const PRESENTER_FRAME_PADDING_Y = 64;
+
+/**
+ * Duration of the cross-fade between slides AND of the first-frame
+ * fade-in, in milliseconds.
+ */
+export const PRESENTER_SLIDE_FADE_MS = 100;
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..d86ebac05
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useBrowserFullscreen.ts
@@ -0,0 +1,80 @@
+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(() => {
+ 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 (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 (!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/useFitScale.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useFitScale.ts
new file mode 100644
index 000000000..86650271b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useFitScale.ts
@@ -0,0 +1,117 @@
+import { RefObject, useEffect, useState } from 'react';
+
+import {
+ PRESENTER_FRAME_PADDING_X,
+ PRESENTER_FRAME_PADDING_Y,
+ PRESENTER_SLIDE_DESIGN_WIDTH,
+ PRESENTER_SLIDE_MAX_SCALE,
+ PRESENTER_SLIDE_MIN_SCALE,
+} from '../constants';
+
+/**
+ * Dimensions of a slide scaled to fit its frame. `null` until measured —
+ * render the slide at opacity 0 until then to avoid a scale-jump on first
+ * paint.
+ */
+export interface FitScale {
+ /** Clamped scale factor to apply via `transform: scale(...)`. */
+ scale: number;
+ /** Visible width of the scaled slide (`designWidth × scale`). */
+ outerWidth: number;
+ /** Painted height of the scaled inner content (`naturalHeight × scale`). */
+ stageHeight: number;
+}
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, value));
+
+/**
+ * Pure scaling formula, extracted so it can be unit-tested without the DOM.
+ * `naturalHeight` is the inner's un-transformed `scrollHeight`; `frameWidth`/
+ * `frameHeight` are the frame's client box. Honours the more constraining axis
+ * (`min(scaleW, scaleH)`) then clamps into `[MIN, MAX]`: below MIN the slide
+ * scrolls (pure CSS), above MAX sparse slides are capped. Returns `null` for
+ * non-positive inputs (transient zero sizes during mount).
+ *
+ * `transform: scale(...)` does not affect layout boxes, so reading
+ * `naturalHeight` off the same element the consumer scales is safe.
+ */
+export const computeFitScale = (
+ naturalHeight: number,
+ frameWidth: number,
+ frameHeight: number,
+): FitScale | null => {
+ const availW = frameWidth - 2 * PRESENTER_FRAME_PADDING_X;
+ const availH = frameHeight - 2 * PRESENTER_FRAME_PADDING_Y;
+ if (naturalHeight <= 0 || availW <= 0 || availH <= 0) {
+ return null;
+ }
+
+ const scale = clamp(
+ Math.min(availW / PRESENTER_SLIDE_DESIGN_WIDTH, availH / naturalHeight),
+ PRESENTER_SLIDE_MIN_SCALE,
+ PRESENTER_SLIDE_MAX_SCALE,
+ );
+
+ return {
+ scale,
+ outerWidth: PRESENTER_SLIDE_DESIGN_WIDTH * scale,
+ stageHeight: naturalHeight * scale,
+ };
+};
+
+/**
+ * Reactively fit a slide's content into the available frame. Returns the
+ * scaled dimensions, or `null` until the first measurement. Re-measures
+ * whenever `inner` (content height) or `frame` (available area) resizes —
+ * viewport resize, fullscreen toggle, late image load, font swap.
+ *
+ * No rAF / re-render guard: committing only sets the outer width (the outer is
+ * `position: absolute`, so it cannot resize the observed frame), the stage
+ * height (the stage is not observed), and the inner's `transform` (ignored by
+ * ResizeObserver, which reports the layout box). No committed value resizes an
+ * observed element, so the observer cannot re-fire itself — there is no loop
+ * to coalesce or debounce. (Trade-off: true sub-pixel oscillation of the
+ * observed boxes would cause a few cheap re-renders rather than being swallowed
+ * — acceptable, and not worth a guard.)
+ *
+ * SSR-safe: DOM/ResizeObserver are touched only inside the effect.
+ *
+ * `inner` must be rendered at `PRESENTER_SLIDE_DESIGN_WIDTH` with no transform
+ * during measurement.
+ */
+export const useFitScale = (
+ innerRef: RefObject,
+ frameRef: RefObject,
+): FitScale | null => {
+ const [fit, setFit] = useState(null);
+
+ useEffect(() => {
+ const inner = innerRef.current;
+ const frame = frameRef.current;
+ if (!inner || !frame || typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+
+ const measure = () => {
+ setFit(
+ computeFitScale(
+ inner.scrollHeight,
+ frame.clientWidth,
+ frame.clientHeight,
+ ),
+ );
+ };
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(inner);
+ observer.observe(frame);
+ // Measure synchronously for a flicker-free first paint instead of waiting
+ // for the observer's initial (async) callback.
+ measure();
+
+ return () => observer.disconnect();
+ }, [innerRef, frameRef]);
+
+ return fit;
+};
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..e2a113e9a
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useSlides.ts
@@ -0,0 +1,42 @@
+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[] = [];
+
+ 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]);
+};
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';
diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock
index a1d41368c..dab6143ce 100644
--- a/src/frontend/yarn.lock
+++ b/src/frontend/yarn.lock
@@ -2063,10 +2063,10 @@
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.3.tgz#7aca824ba61d343a7905dc90c8a8bbdbce8f9a09"
integrity sha512-OgP28CqlPi35wQPul1Dr52SngACXAk8buLGqHYXDp23fbTOJThqarrZE/pgJHoc9Ndwiu7ngwBSO4rZ7OPyMpA==
-"@gouvfr-lasuite/ui-kit@0.23.1":
- version "0.23.1"
- resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.23.1.tgz#fc82cd611ea84e5adef9869800e27cf626ca211c"
- integrity sha512-H9+0zXxvAoCQYRLsq4wChx8KkpxcLEDrFZyIwrHNMqDDgV4YcSgeUIYE5clCRybnglrgK7ZcNGJdMqzf9sji/Q==
+"@gouvfr-lasuite/ui-kit@0.23.2":
+ version "0.23.2"
+ resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.23.2.tgz#9a7531fb35821b75b786bfc3167ad53aab116c4c"
+ integrity sha512-o6bQjqJadBbWMbFiwDYwVeNKYAD/EGeU2W0/omqNgTEYWjKQCGYwgcxgeYR+SZN9GnWKCXEARs18aYgBl+oRbQ==
dependencies:
"@dnd-kit/core" "6.3.1"
"@dnd-kit/modifiers" "9.0.0"