✨(frontend) open and share a presentation at a given slide

Support ?view=present&slide=N deep-links (clamped, URL kept in sync) and
a "Copy link to slide" action in the floating bar. Ignore the deep-link
on mobile.

Closes #2397
This commit is contained in:
Nathan Panchout
2026-07-22 11:16:35 +02:00
parent b689ee59a6
commit 33ca348b02
4 changed files with 224 additions and 60 deletions
@@ -1,14 +1,17 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { DropdownMenu, DropdownMenuItem } from '@gouvfr-lasuite/ui-kit';
import {
ChevronLeft,
ChevronRight,
Link,
Maximize,
Minimize,
Share,
XMark,
} from '@gouvfr-lasuite/ui-kit/icons';
import { useEffect, useRef } from 'react';
import { MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Text } from '@/components';
@@ -18,13 +21,14 @@ interface PresenterFloatingBarProps {
isFullscreen: boolean;
onPrev: () => void;
onNext: () => void;
onCopyLink: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
}
const barCss = css`
position: fixed;
bottom: 1.5rem;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1;
@@ -45,7 +49,28 @@ const separatorCss = css`
width: 1px;
height: 1.25rem;
background: var(--c--theme--colors--greyscale-200, #e5e5e5);
margin: 0 0.25rem;
`;
// The ui-kit DropdownMenu renders its popover in a React Aria portal at the
// document root, outside the presenter overlay's stacking context — so its
// z-index (and width) can only be raised above the overlay via a global style.
// Mounted only while the menu is open (see render below).
const presenterDropdownLayerZIndex = 1002;
const PresenterDropdownLayerStyle = createGlobalStyle`
.react-aria-Popover {
z-index: ${presenterDropdownLayerZIndex};
}
.react-aria-Popover .c__dropdown-menu--tiny {
width: 150px;
min-width: 150px;
}
.react-aria-Popover .c__dropdown-menu--tiny .c__dropdown-menu-item {
box-sizing: border-box;
height: 32px;
}
`;
export const PresenterFloatingBar = ({
@@ -54,12 +79,14 @@ export const PresenterFloatingBar = ({
isFullscreen,
onPrev,
onNext,
onCopyLink,
onToggleFullscreen,
onClose,
}: PresenterFloatingBarProps) => {
const { t } = useTranslation();
const isFirst = index <= 0;
const isLast = index >= total - 1;
const [isActionsOpen, setIsActionsOpen] = useState(false);
// 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
@@ -76,53 +103,97 @@ export const PresenterFloatingBar = ({
return () => cancelAnimationFrame(id);
}, []);
// The DropdownMenu is controlled (isOpen/onOpenChange); stop the click from
// also reaching the overlay so the menu toggles cleanly.
const toggleActions = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
event.preventDefault();
setIsActionsOpen((isOpen) => !isOpen);
};
const actionOptions = useMemo<DropdownMenuItem[]>(
() => [
{
label: t('Copy link to slide'),
icon: <Link aria-hidden="true" width="16" height="16" />,
callback: onCopyLink,
},
],
[onCopyLink, t],
);
return (
<Box
ref={barRef}
$direction="row"
$align="center"
$css={barCss}
role="toolbar"
aria-label={t('Presenter controls')}
>
<Button
size="small"
color="neutral"
variant="tertiary"
disabled={isFirst}
onClick={onPrev}
aria-label={t('Previous slide')}
icon={<ChevronLeft />}
/>
<Text as="span" $size="sm" $color="neutral" aria-hidden="true">
{index + 1} / {total}
</Text>
<Button
size="small"
color="neutral"
variant="tertiary"
disabled={isLast}
onClick={onNext}
aria-label={t('Next slide')}
icon={<ChevronRight />}
/>
<Box $css={separatorCss} aria-hidden />
<Button
size="small"
color="neutral"
variant="tertiary"
onClick={onToggleFullscreen}
aria-label={isFullscreen ? t('Exit fullscreen') : t('Enter fullscreen')}
icon={isFullscreen ? <Minimize /> : <Maximize />}
/>
<Button
size="small"
color="neutral"
variant="tertiary"
onClick={onClose}
aria-label={t('Close presenter')}
icon={<XMark />}
/>
</Box>
<>
{isActionsOpen && <PresenterDropdownLayerStyle />}
<Box
ref={barRef}
$direction="row"
$align="center"
$css={barCss}
role="toolbar"
aria-label={t('Presenter controls')}
>
<Button
size="nano"
color="neutral"
variant="tertiary"
disabled={isFirst}
onClick={onPrev}
aria-label={t('Previous slide')}
icon={<ChevronLeft size="small" />}
/>
<Text as="span" $size="sm" $color="neutral" aria-hidden="true">
{index + 1} / {total}
</Text>
<Button
size="nano"
color="neutral"
variant="tertiary"
disabled={isLast}
onClick={onNext}
aria-label={t('Next slide')}
icon={<ChevronRight size="small" />}
/>
<Box $css={separatorCss} aria-hidden />
<DropdownMenu
options={actionOptions}
isOpen={isActionsOpen}
onOpenChange={setIsActionsOpen}
shouldCloseOnInteractOutside={() => true}
variant="tiny"
>
<Button
size="nano"
color="neutral"
variant="tertiary"
onClick={toggleActions}
aria-label={t('More options')}
aria-expanded={isActionsOpen}
aria-haspopup="menu"
icon={<Share size="small" />}
/>
</DropdownMenu>
<Button
size="nano"
color="neutral"
variant="tertiary"
onClick={onToggleFullscreen}
aria-label={
isFullscreen ? t('Exit fullscreen') : t('Enter fullscreen')
}
icon={
isFullscreen ? <Minimize size="small" /> : <Maximize size="small" />
}
/>
<Button
size="nano"
color="neutral"
variant="tertiary"
onClick={onClose}
aria-label={t('Close presenter')}
icon={<XMark size="small" />}
/>
</Box>
</>
);
};
@@ -11,6 +11,7 @@ import { Doc, getEmojiAndTitle } from '@/docs/doc-management';
import { PRESENTER_WINDOW_RADIUS } from '../constants';
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
import { useCopyPresenterLink } from '../hooks/useCopyPresenterLink';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
import { getSlideTitle, useSlides } from '../hooks/useSlides';
import type { PresenterBlock, PresenterSlideData } from '../types';
@@ -22,6 +23,8 @@ interface PresenterOverlayProps {
doc: Doc;
initialSlideIndex: number;
onClose: () => void;
/** Notified whenever the current slide changes (used to sync the URL). */
onIndexChange?: (index: number) => void;
}
const overlayCss = css`
@@ -47,9 +50,11 @@ export const PresenterOverlay = ({
doc,
initialSlideIndex,
onClose,
onIndexChange,
}: PresenterOverlayProps) => {
const { t } = useTranslation();
const editor = useEditorStore((state) => state.editor);
const copyPresenterLink = useCopyPresenterLink(doc.id);
// Snapshot the editor's blocks once at mount. Subsequent collaborator
// edits do not affect the ongoing presentation (by design).
@@ -83,6 +88,25 @@ export const PresenterOverlay = ({
setCurrentIndex(clamp(initialSlideIndex));
}, [initialSlideIndex, clamp]);
// `total` is only known after slides are computed, so a deep-link with an
// out-of-range slide (e.g. `slide=99`) is snapped to the last slide here.
useEffect(() => {
setCurrentIndex((i) => clamp(i));
}, [clamp]);
// Keep the URL (or any consumer) in sync with the displayed slide. Also fires
// on mount, so opening manually writes `slide=1` to the address bar. The ref
// guard ensures we only notify on a real slide change: `onIndexChange` may
// change identity when the consumer rewrites the URL, and re-emitting the
// same index would loop.
const lastEmittedRef = useRef<number | null>(null);
useEffect(() => {
if (lastEmittedRef.current !== currentIndex) {
lastEmittedRef.current = currentIndex;
onIndexChange?.(currentIndex);
}
}, [currentIndex, onIndexChange]);
const goPrev = useCallback(
() => setCurrentIndex((i) => clamp(i - 1)),
[clamp],
@@ -149,7 +173,7 @@ export const PresenterOverlay = ({
}
return createPortal(
<FocusScope contain autoFocus restoreFocus>
<FocusScope autoFocus restoreFocus>
<Box
$css={overlayCss}
role="dialog"
@@ -177,6 +201,7 @@ export const PresenterOverlay = ({
isFullscreen={isFullscreen}
onPrev={goPrev}
onNext={goNext}
onCopyLink={() => copyPresenterLink(currentIndex)}
onToggleFullscreen={() => void toggle()}
onClose={onClose}
/>
@@ -1,5 +1,6 @@
import dynamic from 'next/dynamic';
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo } from 'react';
import { css } from 'styled-components';
import { Loading } from '@/components';
@@ -26,24 +27,58 @@ const PresenterOverlay = dynamic(
/**
* 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.
* Drives both the manual "Present" action (via `usePresenterStore`) and the
* `?view=present[&slide=N]` deep-link (via the URL), painting a boot cover
* until the editor snapshot is available.
*/
export const PresenterRoot = () => {
const router = useRouter();
const { isMobile } = useResponsiveStore();
const editor = useEditorStore((state) => state.editor);
const { currentDoc } = useDocStore();
const { initialSlideIndex, isOpen, close } = usePresenterStore();
const urlSearchParams = useMemo(() => {
const queryString = router.asPath.split('?')[1]?.split('#')[0] ?? '';
return new URLSearchParams(queryString);
}, [router.asPath]);
const viewParam = router.query.view ?? urlSearchParams.get('view');
const slideQueryParam = router.query.slide ?? urlSearchParams.get('slide');
const slideParam = Number(
Array.isArray(slideQueryParam) ? slideQueryParam[0] : slideQueryParam,
);
const wantsPresent =
(Array.isArray(viewParam) ? viewParam[0] : viewParam) === 'present';
const urlInitialIndex =
Number.isFinite(slideParam) && slideParam >= 1 ? slideParam - 1 : 0;
const syncSlideToUrl = useCallback(
(index: number) => {
void router.replace(
{
pathname: router.pathname,
query: { ...router.query, view: 'present', slide: index + 1 },
},
undefined,
{ shallow: true },
);
},
[router],
);
const handleClose = useCallback(() => {
close();
}, [close]);
const { view: _view, slide: _slide, ...rest } = router.query;
void router.replace({ pathname: router.pathname, query: rest }, undefined, {
shallow: true,
});
}, [close, router]);
useEffect(() => {
if (isMobile && isOpen) {
close();
handleClose();
}
}, [isMobile, isOpen, close]);
}, [isMobile, isOpen, handleClose]);
useEffect(() => {
return () => {
@@ -51,7 +86,10 @@ export const PresenterRoot = () => {
};
}, [close]);
const active = !isMobile && isOpen;
// The presenter is active from a manual open OR a deep-link; deriving it from
// `wantsPresent` lets the cover paint on the very first render, before the
// layout cascade shows.
const active = !isMobile && (isOpen || wantsPresent);
const isBooting = active && (!editor || !currentDoc);
// Let users escape the boot cover if the editor never finishes loading.
@@ -76,10 +114,14 @@ export const PresenterRoot = () => {
return <Loading $css={coverCss} />;
}
// A manual open carries its own start slide; a deep-link reads it from the URL.
const startIndex = isOpen ? initialSlideIndex : urlInitialIndex;
return (
<PresenterOverlay
doc={currentDoc}
initialSlideIndex={initialSlideIndex}
initialSlideIndex={startIndex}
onIndexChange={syncSlideToUrl}
onClose={handleClose}
/>
);
@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Doc } from '@/docs/doc-management';
import { useClipboard } from '@/hooks';
/**
* Builds and copies a deep-link that reopens the document in presenter mode at
* a given slide. `index` is the 0-based slide index; the URL uses a 1-based
* `slide` param (see issue #2397).
*/
export const useCopyPresenterLink = (docId: Doc['id']) => {
const { t } = useTranslation();
const copyToClipboard = useClipboard();
return useCallback(
(index: number) => {
copyToClipboard(
`${window.location.origin}/docs/${docId}/?view=present&slide=${index + 1}`,
t('Link Copied !'),
t('Failed to copy link'),
);
},
[copyToClipboard, docId, t],
);
};