This commit is contained in:
Nathan Panchout
2026-04-24 18:50:51 +02:00
parent e747e038f8
commit b139069425
14 changed files with 1093 additions and 0 deletions

5
.gitignore vendored
View File

@@ -82,3 +82,8 @@ db.sqlite3
# Cursor rules
.cursorrules
# Claude
CLAUDE.md
.claude/
openspec/

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg';
import CoPresentSVG from '@/assets/icons/ui-kit/co_present.svg';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
import DownloadSVG from '@/assets/icons/ui-kit/download.svg';
@@ -79,6 +80,14 @@ const ModalExport =
)
: null;
const PresenterOverlay = dynamic(
() =>
import('@/docs/doc-presenter').then((mod) => ({
default: mod.PresenterOverlay,
})),
{ ssr: false },
);
interface DocToolBoxProps {
doc: Doc;
}
@@ -93,6 +102,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 +186,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
showSeparator: true,
show: !emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Present'),
icon: <CoPresentSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsPresenterOpen(true);
},
show: !doc.deleted_at && !isSmallMobile,
testId: `docs-actions-present-${doc.id}`,
},
{
label: t('Copy link'),
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />,
@@ -320,6 +339,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
{isPresenterOpen && (
<PresenterOverlay
doc={doc}
onClose={() => {
setIsPresenterOpen(false);
restoreFocus();
}}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,141 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import { AppWrapper } from '@/tests/utils';
const requestFullscreen = vi.fn(async () => {});
const exitFullscreen = vi.fn(async () => {});
vi.mock('@/docs/doc-editor/components/BlockNoteEditor', () => ({
blockNoteSchema: {},
}));
vi.mock('@/docs/doc-editor/styles', () => ({
cssEditor: '',
}));
vi.mock('@blocknote/mantine', () => ({
BlockNoteView: ({ editor: _editor }: { editor: unknown }) => (
<div data-testid="blocknote-view" />
),
}));
vi.mock('@blocknote/react', () => ({
useCreateBlockNote: () => ({}),
}));
const editorDocument = [
{ type: 'heading', content: [{ type: 'text', text: 'Slide 1' }] },
{ type: 'divider' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Slide 2 body' }] },
{ type: 'divider' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Slide 3 body' }] },
{ type: 'divider' },
// Empty group between two dividers — must be dropped.
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
];
vi.mock('@/docs/doc-editor/stores', () => ({
useEditorStore: (selector: (s: unknown) => unknown) =>
selector({ editor: { document: editorDocument } }),
}));
import { PresenterOverlay } from '../components/PresenterOverlay';
describe('PresenterOverlay', () => {
beforeEach(() => {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => null,
});
Object.defineProperty(document.documentElement, 'requestFullscreen', {
configurable: true,
value: requestFullscreen,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
requestFullscreen.mockClear();
exitFullscreen.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
const doc = { id: 'd1', deleted_at: null } as never;
test('renders 3 slides (empty group dropped) and starts at slide 1/3', () => {
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={vi.fn()} />
</AppWrapper>,
);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
test('ArrowRight navigates to the next slide', () => {
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={vi.fn()} />
</AppWrapper>,
);
fireEvent.keyDown(window, { code: 'ArrowRight' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
});
test('clicking close invokes onClose', () => {
const onClose = vi.fn();
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={onClose} />
</AppWrapper>,
);
fireEvent.click(screen.getByRole('button', { name: 'Close presenter' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('next is disabled on the last slide', () => {
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={vi.fn()} />
</AppWrapper>,
);
fireEvent.keyDown(window, { code: 'End' });
expect(screen.getByText('3 / 3')).toBeInTheDocument();
expect(
(screen.getByRole('button', { name: 'Next slide' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('mounting does NOT auto-enter fullscreen', () => {
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={vi.fn()} />
</AppWrapper>,
);
expect(requestFullscreen).not.toHaveBeenCalled();
});
test('clicking the fullscreen toggle calls requestFullscreen on documentElement', () => {
render(
<AppWrapper>
<PresenterOverlay doc={doc} onClose={vi.fn()} />
</AppWrapper>,
);
requestFullscreen.mockClear();
fireEvent.click(
screen.getByRole('button', { name: 'Enter fullscreen' }),
);
expect(requestFullscreen).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,94 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
describe('useBrowserFullscreen', () => {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(async () => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event('fullscreenchange'));
});
const exitFullscreen = vi.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
});
beforeEach(() => {
fullscreenElement = null;
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document.documentElement, 'requestFullscreen', {
configurable: true,
value: requestFullscreen,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
requestFullscreen.mockClear();
exitFullscreen.mockClear();
});
afterEach(() => {
fullscreenElement = null;
});
test('initial state reflects current fullscreen state', () => {
const { result } = renderHook(() => useBrowserFullscreen());
expect(result.current.isFullscreen).toBe(false);
});
test('enter() requests fullscreen and updates state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreen).toBe(true);
});
test('enter() is a no-op if already fullscreen', async () => {
fullscreenElement = document.documentElement;
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
expect(requestFullscreen).not.toHaveBeenCalled();
});
test('exit() leaves fullscreen and updates state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
await act(async () => {
await result.current.exit();
});
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreen).toBe(false);
});
test('toggle() flips state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.toggle();
});
expect(result.current.isFullscreen).toBe(true);
await act(async () => {
await result.current.toggle();
});
expect(result.current.isFullscreen).toBe(false);
});
test('reacts to external fullscreenchange events', () => {
const { result } = renderHook(() => useBrowserFullscreen());
act(() => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event('fullscreenchange'));
});
expect(result.current.isFullscreen).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
const renderShortcuts = (
overrides: Partial<Parameters<typeof usePresenterShortcuts>[0]> = {},
) => {
const handlers = {
onPrev: vi.fn(),
onNext: vi.fn(),
onFirst: vi.fn(),
onLast: vi.fn(),
onToggleFullscreen: vi.fn(),
onClose: vi.fn(),
isFullscreen: false,
...overrides,
};
renderHook(() => usePresenterShortcuts(handlers));
return handlers;
};
const press = (init: KeyboardEventInit) => {
const event = new KeyboardEvent('keydown', { ...init, cancelable: true });
window.dispatchEvent(event);
return event;
};
describe('usePresenterShortcuts', () => {
test('ArrowLeft and PageUp call onPrev', () => {
const h = renderShortcuts();
press({ code: 'ArrowLeft' });
press({ code: 'PageUp' });
expect(h.onPrev).toHaveBeenCalledTimes(2);
});
test('ArrowRight, PageDown and Space call onNext', () => {
const h = renderShortcuts();
press({ code: 'ArrowRight' });
press({ code: 'PageDown' });
press({ code: 'Space' });
expect(h.onNext).toHaveBeenCalledTimes(3);
});
test('Home calls onFirst, End calls onLast', () => {
const h = renderShortcuts();
press({ code: 'Home' });
press({ code: 'End' });
expect(h.onFirst).toHaveBeenCalledTimes(1);
expect(h.onLast).toHaveBeenCalledTimes(1);
});
test('KeyF toggles fullscreen but ignores modifiers', () => {
const h = renderShortcuts();
press({ code: 'KeyF' });
press({ code: 'KeyF', metaKey: true });
press({ code: 'KeyF', ctrlKey: true });
expect(h.onToggleFullscreen).toHaveBeenCalledTimes(1);
});
test('Escape calls onClose only when not fullscreen', () => {
const h1 = renderShortcuts({ isFullscreen: false });
press({ code: 'Escape' });
expect(h1.onClose).toHaveBeenCalledTimes(1);
const h2 = renderShortcuts({ isFullscreen: true });
press({ code: 'Escape' });
expect(h2.onClose).not.toHaveBeenCalled();
});
test('Space prevents default to avoid page scroll', () => {
renderShortcuts();
const event = press({ code: 'Space' });
expect(event.defaultPrevented).toBe(true);
});
test('Arrow keys prevent default', () => {
renderShortcuts();
expect(press({ code: 'ArrowLeft' }).defaultPrevented).toBe(true);
expect(press({ code: 'ArrowRight' }).defaultPrevented).toBe(true);
});
test('non-arrow repeat events are ignored', () => {
const h = renderShortcuts();
press({ code: 'Space', repeat: true });
expect(h.onNext).not.toHaveBeenCalled();
});
test('arrow repeat events are accepted', () => {
const h = renderShortcuts();
press({ code: 'ArrowRight', repeat: true });
expect(h.onNext).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from 'vitest';
import { isEmptyBlock, splitBlocksIntoSlides } from '../hooks/useSlides';
const para = (text = 'hello') => ({
type: 'paragraph',
content: text === '' ? [] : [{ type: 'text', text }],
});
const heading = (text = 'Title', level = 1) => ({
type: 'heading',
content: [{ type: 'text', text }],
props: { level },
});
const divider = () => ({ type: 'divider' });
const image = () => ({ type: 'image', props: { url: 'x' } });
describe('isEmptyBlock', () => {
test('empty paragraph (no content array entries) is empty', () => {
expect(isEmptyBlock(para(''))).toBe(true);
});
test('whitespace-only paragraph is empty', () => {
expect(isEmptyBlock(para(' '))).toBe(true);
});
test('paragraph with text is not empty', () => {
expect(isEmptyBlock(para('hi'))).toBe(false);
});
test('heading with whitespace is empty', () => {
expect(isEmptyBlock(heading(' '))).toBe(true);
});
test('image is never empty', () => {
expect(isEmptyBlock(image() as any)).toBe(false);
});
test('divider is not "empty" (it is filtered separately)', () => {
expect(isEmptyBlock(divider() as any)).toBe(false);
});
test('block with children is not empty', () => {
const b = { type: 'paragraph', content: [], children: [para()] };
expect(isEmptyBlock(b as any)).toBe(false);
});
});
describe('splitBlocksIntoSlides', () => {
test('no divider yields one slide', () => {
const result = splitBlocksIntoSlides([para('a'), para('b')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2);
});
test('one divider yields two slides', () => {
const result = splitBlocksIntoSlides([para('a'), divider(), para('b')]);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(1);
expect(result[1]).toHaveLength(1);
});
test('leading divider does not produce an empty slide', () => {
const result = splitBlocksIntoSlides([divider(), para('a')]);
expect(result).toHaveLength(1);
});
test('trailing divider does not produce an empty slide', () => {
const result = splitBlocksIntoSlides([para('a'), divider()]);
expect(result).toHaveLength(1);
});
test('consecutive dividers do not produce empty slides', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
divider(),
divider(),
para('b'),
]);
expect(result).toHaveLength(2);
});
test('empty doc yields one empty slide', () => {
const result = splitBlocksIntoSlides([]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(0);
});
test('divider-only doc yields one empty slide', () => {
const result = splitBlocksIntoSlides([divider(), divider()]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(0);
});
test('group of only empty paragraphs is dropped', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
para(''),
para(' '),
divider(),
para('b'),
]);
expect(result).toHaveLength(2);
expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
});
test('group with one empty + one non-empty paragraph keeps only the non-empty', () => {
const result = splitBlocksIntoSlides([para(''), para('hi'), para(' ')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toMatchObject({ content: [{ text: 'hi' }] });
});
test('image-only group is kept', () => {
const result = splitBlocksIntoSlides([para('a'), divider(), image()]);
expect(result).toHaveLength(2);
expect(result[1]).toHaveLength(1);
});
test('heading with whitespace is filtered', () => {
const result = splitBlocksIntoSlides([heading(' '), para('body')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toMatchObject({ type: 'paragraph' });
});
});

View File

@@ -0,0 +1,116 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, 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: 0.25rem 0.5rem;
background: white;
border: 1px solid var(--c--theme--colors--greyscale-200, #e5e5e5);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
font-variant-numeric: tabular-nums;
white-space: nowrap;
`;
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 (
<Box
$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={<Icon iconName="chevron_left" $color="inherit" aria-hidden />}
/>
<Text
as="span"
$size="sm"
aria-label={t('Slide {{current}} of {{total}}', {
current: index + 1,
total,
})}
>
{index + 1} / {total}
</Text>
<Button
size="small"
color="neutral"
variant="tertiary"
disabled={isLast}
onClick={onNext}
aria-label={t('Next slide')}
icon={<Icon iconName="chevron_right" $color="inherit" aria-hidden />}
/>
<Box $css={separatorCss} aria-hidden />
<Button
size="small"
color="neutral"
variant="tertiary"
onClick={onToggleFullscreen}
aria-label={isFullscreen ? t('Exit fullscreen') : t('Enter fullscreen')}
icon={
<Icon
iconName={isFullscreen ? 'fullscreen_exit' : 'fullscreen'}
$color="inherit"
aria-hidden
/>
}
/>
<Button
size="small"
color="neutral"
variant="tertiary"
onClick={onClose}
aria-label={t('Close presenter')}
icon={<Icon iconName="close" $color="inherit" aria-hidden />}
/>
</Box>
);
};

View File

@@ -0,0 +1,189 @@
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, Text } 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: #f2f3f5;
display: flex;
flex-direction: column;
`;
const labelCss = css`
position: fixed;
top: 0.75rem;
left: 1rem;
color: var(--c--theme--colors--greyscale-500, #8a8a8a);
font-size: 0.8125rem;
pointer-events: none;
`;
const slideAreaCss = css`
flex: 1;
/* Plain block layout — flex centering breaks vertical scrolling
for content taller than the container. */
display: block;
overflow: hidden;
padding: 2rem 2rem 6rem;
`;
const slideFrameCss = css`
width: 100%;
height: 100%;
margin: 0 auto;
background: white;
border-radius: 0.25rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
/* The slide frame is the vertical scroll container. */
overflow-y: auto;
overflow-x: hidden;
`;
const slideWrapperCss = css`
width: 100%;
/* min-height ensures short slides still fill the frame so the editor
can compute its 100%-height layout, but tall slides expand and scroll. */
min-height: 100%;
padding: 3rem 0;
box-sizing: border-box;
`;
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, exit, toggle } = useBrowserFullscreen();
// Leave fullscreen on unmount if the user entered it via the bar.
useEffect(() => {
return () => {
void exit();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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(
<Box
$css={overlayCss}
role="dialog"
aria-modal="true"
className="toto"
aria-label={t('Presenter mode')}
>
<Text as="span" $css={labelCss}>
{t('Docs - Presenter mode')}
</Text>
<Box $css={slideAreaCss}>
<Box $css={slideFrameCss}>
{mountedIndices.map((i) => (
<Box
key={i}
$css={css`
${slideWrapperCss};
display: ${i === currentIndex ? 'block' : 'none'};
`}
>
<PresenterSlide
blocks={slides[i] as unknown[]}
ariaLabel={t('Slide {{current}} of {{total}}', {
current: i + 1,
total,
})}
/>
</Box>
))}
</Box>
</Box>
<PresenterFloatingBar
index={currentIndex}
total={total}
isFullscreen={isFullscreen}
onPrev={goPrev}
onNext={goNext}
onToggleFullscreen={() => void toggle()}
onClose={onClose}
/>
</Box>,
document.body,
);
};

View File

@@ -0,0 +1,58 @@
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';
import { PRESENTER_SLIDE_MAX_WIDTH } from '../constants';
interface PresenterSlideProps {
blocks: unknown[];
ariaLabel?: string;
}
const slideCss = css`
${cssEditor};
width: 100%;
max-width: ${PRESENTER_SLIDE_MAX_WIDTH}px;
margin: 0 auto;
padding: 0 1.5rem;
/* 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<typeof useCreateBlockNote>[0]>['initialContent'])
: undefined,
schema: blockNoteSchema,
});
return (
<Box
$css={slideCss}
role="group"
aria-label={ariaLabel ?? t('Presenter slide')}
>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
</Box>
);
};

View File

@@ -0,0 +1,9 @@
/**
* 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;
export const PRESENTER_SLIDE_MAX_WIDTH = 868;

View File

@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from 'react';
const isCurrentlyFullscreen = () =>
typeof document !== 'undefined' && !!document.fullscreenElement;
export const useBrowserFullscreen = () => {
const [isFullscreen, setIsFullscreen] = useState<boolean>(
isCurrentlyFullscreen,
);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const handleChange = () => setIsFullscreen(isCurrentlyFullscreen());
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();
} 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.
}
}, []);
const toggle = useCallback(async () => {
if (isCurrentlyFullscreen()) {
await exit();
} else {
await enter();
}
}, [enter, exit]);
return { isFullscreen, enter, exit, toggle };
};

View File

@@ -0,0 +1,82 @@
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 'ArrowRight':
case 'PageDown':
case 'Space':
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,
]);
};

View File

@@ -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<string, unknown>;
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 = <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 cleaned = groups
.map((group) => group.filter((b) => !isEmptyBlock(b)))
.filter((group) => group.length > 0);
return cleaned.length > 0 ? cleaned : [[]];
};
export const useSlides = <T extends Block>(blocks: T[]): T[][] => {
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
};

View File

@@ -0,0 +1 @@
export { PresenterOverlay } from './components/PresenterOverlay';