mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
✨(frontend) add presenter mode
Add a presenter overlay that turns the current document into a slide deck. The editor's blocks are snapshot at open time and split into slides on each divider; navigation is driven by keyboard shortcuts and a floating bar with browser fullscreen support. The overlay is wired to the doc header toolbox via a new "Present" entry, lazy-loaded to keep the editor bundle lean.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <Present width={24} height={24} aria-hidden="true" />,
|
||||
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 && (
|
||||
<PresenterOverlay
|
||||
doc={doc}
|
||||
onClose={() => {
|
||||
setIsPresenterOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+140
@@ -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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
barRef.current
|
||||
?.querySelector<HTMLButtonElement>('button:not([disabled])')
|
||||
?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<Text
|
||||
as="span"
|
||||
className="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{t('Slide {{current}} of {{total}}', {
|
||||
current: 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>
|
||||
);
|
||||
};
|
||||
+146
@@ -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<unknown[] | null>(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<HTMLDivElement>(null);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Box
|
||||
$css={overlayCss}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('Presenter mode')}
|
||||
>
|
||||
<Box ref={frameRef} $css={slideAreaCss}>
|
||||
{mountedIndices.map((i) => (
|
||||
<PresenterSlide
|
||||
key={i}
|
||||
blocks={slides[i] as unknown[]}
|
||||
frameRef={frameRef}
|
||||
isCurrent={i === currentIndex}
|
||||
ariaLabel={t('Slide {{current}} of {{total}}', {
|
||||
current: i + 1,
|
||||
total,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<PresenterFloatingBar
|
||||
index={currentIndex}
|
||||
total={total}
|
||||
isFullscreen={isFullscreen}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onToggleFullscreen={() => void toggle()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Box>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
+139
@@ -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<HTMLDivElement | null>;
|
||||
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<HTMLDivElement>(null);
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent:
|
||||
// BlockNote rejects an empty initialContent array — fall back to one empty paragraph.
|
||||
blocks.length > 0
|
||||
? (blocks as NonNullable<
|
||||
Parameters<typeof useCreateBlockNote>[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 (
|
||||
<Box
|
||||
$css={outerCss}
|
||||
style={outerStyle}
|
||||
role="group"
|
||||
aria-roledescription={t('slide')}
|
||||
aria-label={ariaLabel ?? t('Presenter slide')}
|
||||
aria-hidden={!isCurrent}
|
||||
>
|
||||
<Box $css={stageCss} style={stageStyle}>
|
||||
<Box ref={innerRef} $css={innerCss} style={innerStyle}>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
editable={false}
|
||||
theme="light"
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
comments={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
+80
@@ -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<boolean>(
|
||||
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 };
|
||||
};
|
||||
@@ -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<HTMLDivElement | null>,
|
||||
frameRef: RefObject<HTMLDivElement | null>,
|
||||
): FitScale | null => {
|
||||
const [fit, setFit] = useState<FitScale | null>(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;
|
||||
};
|
||||
+99
@@ -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,
|
||||
]);
|
||||
};
|
||||
@@ -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 = <T extends Block>(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 = <T extends Block>(blocks: T[]): T[][] => {
|
||||
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PresenterOverlay } from './components/PresenterOverlay';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user