mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-28 20:45:08 +02:00
✨(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:
+122
-51
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+50
-8
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+26
@@ -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],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user