mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 07:02:03 +02:00
Compare commits
6 Commits
websocket/
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09ef7b826 | ||
|
|
f90da06a79 | ||
|
|
11b44a3925 | ||
|
|
5f41d647ce | ||
|
|
8b39393e0c | ||
|
|
99fa2627c9 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -82,3 +82,8 @@ db.sqlite3
|
||||
|
||||
# Cursor rules
|
||||
.cursorrules
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
openspec/
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
|
||||
const openPresenter = async (page: Page) => {
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Present' }).click();
|
||||
|
||||
const overlay = page.getByRole('dialog', { name: 'Presenter mode' });
|
||||
await expect(overlay).toBeVisible();
|
||||
return overlay;
|
||||
};
|
||||
|
||||
const insertDivider = async (page: Page) => {
|
||||
const { suggestionMenu } = await openSuggestionMenu({ page });
|
||||
await suggestionMenu.getByText('Divider', { exact: true }).click();
|
||||
};
|
||||
|
||||
const writeMultiSlideDoc = async (page: Page) => {
|
||||
const editor = await writeInEditor({ page, text: 'Slide one' });
|
||||
await editor.press('Enter');
|
||||
await insertDivider(page);
|
||||
await editor.press('Enter');
|
||||
await writeInEditor({ page, text: 'Slide two' });
|
||||
await editor.press('Enter');
|
||||
await insertDivider(page);
|
||||
await editor.press('Enter');
|
||||
await writeInEditor({ page, text: 'Slide three' });
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Presenter Mode', () => {
|
||||
test('opens the presenter overlay from the doc options menu and closes with Escape', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'presenter-open', browserName, 1);
|
||||
await writeInEditor({ page, text: 'Hello presenter' });
|
||||
|
||||
const overlay = await openPresenter(page);
|
||||
|
||||
await expect(
|
||||
overlay.getByRole('toolbar', { name: 'Presenter controls' }),
|
||||
).toBeVisible();
|
||||
await expect(overlay.getByText('Hello presenter')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(overlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders a single-slide doc with counter 1/1 and disabled nav buttons', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'presenter-single', browserName, 1);
|
||||
await writeInEditor({ page, text: 'Slide A' });
|
||||
|
||||
const overlay = await openPresenter(page);
|
||||
|
||||
await expect(overlay.getByText('1 / 1')).toBeVisible();
|
||||
await expect(
|
||||
overlay.getByRole('button', { name: 'Previous slide' }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
overlay.getByRole('button', { name: 'Next slide' }),
|
||||
).toBeDisabled();
|
||||
await expect(overlay.getByText('Slide A')).toBeVisible();
|
||||
|
||||
await overlay.getByRole('button', { name: 'Close presenter' }).click();
|
||||
await expect(overlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('navigates between slides via the floating bar buttons', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'presenter-nav-bar', browserName, 1);
|
||||
await writeMultiSlideDoc(page);
|
||||
|
||||
const overlay = await openPresenter(page);
|
||||
|
||||
const prev = overlay.getByRole('button', { name: 'Previous slide' });
|
||||
const next = overlay.getByRole('button', { name: 'Next slide' });
|
||||
|
||||
await expect(overlay.getByText('1 / 3')).toBeVisible();
|
||||
await expect(overlay.getByText('Slide one')).toBeVisible();
|
||||
await expect(prev).toBeDisabled();
|
||||
await expect(next).toBeEnabled();
|
||||
|
||||
await next.click();
|
||||
await expect(overlay.getByText('2 / 3')).toBeVisible();
|
||||
await expect(overlay.getByText('Slide two')).toBeVisible();
|
||||
|
||||
await next.click();
|
||||
await expect(overlay.getByText('3 / 3')).toBeVisible();
|
||||
await expect(overlay.getByText('Slide three')).toBeVisible();
|
||||
await expect(next).toBeDisabled();
|
||||
await expect(prev).toBeEnabled();
|
||||
|
||||
await prev.click();
|
||||
await expect(overlay.getByText('2 / 3')).toBeVisible();
|
||||
await expect(overlay.getByText('Slide two')).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates between slides via keyboard shortcuts', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'presenter-nav-keyboard', browserName, 1);
|
||||
await writeMultiSlideDoc(page);
|
||||
|
||||
const overlay = await openPresenter(page);
|
||||
|
||||
await expect(overlay.getByText('1 / 3')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(overlay.getByText('2 / 3')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('End');
|
||||
await expect(overlay.getByText('3 / 3')).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Home');
|
||||
await expect(overlay.getByText('1 / 3')).toBeVisible();
|
||||
|
||||
// ArrowLeft on the first slide is clamped — counter stays at 1 / 3.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(overlay.getByText('1 / 3')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Presenter Mode mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('hides the Present option on small mobile viewports', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: true,
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Present' })).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -40,9 +40,9 @@
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.38",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.3.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@gouvfr-lasuite/ui-kit": "0.20.1",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/hooks": "8.3.18",
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
Modal,
|
||||
ModalProps,
|
||||
ModalDefaultVariantProps,
|
||||
ModalSize,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
@@ -20,7 +20,7 @@ export type AlertModalProps = {
|
||||
title: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
} & Partial<ModalProps>;
|
||||
} & Partial<ModalDefaultVariantProps>;
|
||||
|
||||
export const AlertModal = ({
|
||||
cancelLabel,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalDefaultVariantProps,
|
||||
ModalSize,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
interface SideModalStyleProps {
|
||||
@@ -35,7 +39,7 @@ const SideModalStyle = createGlobalStyle<SideModalStyleProps>`
|
||||
}
|
||||
`;
|
||||
|
||||
type SideModalType = Omit<ComponentPropsWithRef<typeof Modal>, 'size'>;
|
||||
type SideModalType = Omit<ModalDefaultVariantProps, 'size'>;
|
||||
|
||||
type SideModalProps = SideModalType & Partial<SideModalStyleProps>;
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
--c--globals--font--weights--medium: 500;
|
||||
--c--globals--font--weights--bold: 600;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--weights--black: 800;
|
||||
--c--globals--font--families--base:
|
||||
inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--accent:
|
||||
@@ -849,6 +849,18 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -1731,7 +1743,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent:
|
||||
marianne, inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--base:
|
||||
@@ -2539,6 +2550,18 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
|
||||
@@ -372,7 +372,7 @@ export const tokens = {
|
||||
medium: 500,
|
||||
bold: 600,
|
||||
extrabold: 800,
|
||||
black: 900,
|
||||
black: 800,
|
||||
},
|
||||
families: {
|
||||
base: 'Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -664,6 +664,26 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
@@ -1334,7 +1354,7 @@ export const tokens = {
|
||||
'sm-alt': '3.5rem',
|
||||
'xs-alt': '3rem',
|
||||
},
|
||||
weights: { thin: 100, extrabold: 800, black: 900 },
|
||||
weights: { thin: 100, extrabold: 800 },
|
||||
families: {
|
||||
accent:
|
||||
'Marianne, Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -1948,6 +1968,26 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Present, useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -79,6 +79,14 @@ const ModalExport =
|
||||
)
|
||||
: null;
|
||||
|
||||
const PresenterOverlay = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-presenter').then((mod) => ({
|
||||
default: mod.PresenterOverlay,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
}
|
||||
@@ -93,6 +101,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 +185,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
showSeparator: true,
|
||||
show: !emoji && doc.abilities.partial_update && !isTopRoot,
|
||||
},
|
||||
{
|
||||
label: t('Present'),
|
||||
icon: <Present />,
|
||||
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 +338,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
doc={doc}
|
||||
/>
|
||||
)}
|
||||
{isPresenterOpen && (
|
||||
<PresenterOverlay
|
||||
doc={doc}
|
||||
onClose={() => {
|
||||
setIsPresenterOpen(false);
|
||||
restoreFocus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test('exitIfOwned() exits when we initiated the fullscreen', async () => {
|
||||
const { result } = renderHook(() => useBrowserFullscreen());
|
||||
await act(async () => {
|
||||
await result.current.enter();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.exitIfOwned();
|
||||
});
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('exitIfOwned() is a no-op when fullscreen pre-exists', async () => {
|
||||
fullscreenElement = document.documentElement;
|
||||
const { result } = renderHook(() => useBrowserFullscreen());
|
||||
await act(async () => {
|
||||
await result.current.exitIfOwned();
|
||||
});
|
||||
expect(exitFullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exitIfOwned() is a no-op after user exits fullscreen externally', async () => {
|
||||
const { result } = renderHook(() => useBrowserFullscreen());
|
||||
await act(async () => {
|
||||
await result.current.enter();
|
||||
});
|
||||
// User presses Esc — fullscreen ends outside of our control.
|
||||
act(() => {
|
||||
fullscreenElement = null;
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.exitIfOwned();
|
||||
});
|
||||
expect(exitFullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test('Space on a button is ignored to avoid native click double-trigger', () => {
|
||||
const h = renderShortcuts();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
button.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
code: 'Space',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(h.onNext).not.toHaveBeenCalled();
|
||||
document.body.removeChild(button);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize,
|
||||
XMark,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
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;
|
||||
|
||||
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={<ChevronLeft />}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
$size="sm"
|
||||
$color="neutral"
|
||||
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={<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={<Maximize />}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
onClick={onClose}
|
||||
aria-label={t('Close presenter')}
|
||||
icon={<XMark />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
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 { useFocusStore } from '@/stores';
|
||||
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const slideFrameCss = css`
|
||||
width: min(80%, 1400px);
|
||||
height: 100%;
|
||||
background: white;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
width: 95%;
|
||||
}
|
||||
`;
|
||||
|
||||
const slideWrapperCss = css`
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const PresenterOverlay = ({
|
||||
doc: _doc,
|
||||
onClose,
|
||||
}: PresenterOverlayProps) => {
|
||||
const { t } = useTranslation();
|
||||
const editor = useEditorStore((state) => state.editor);
|
||||
const { addLastFocus } = useFocusStore();
|
||||
|
||||
// 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;
|
||||
|
||||
// The presenter is opened from a dropdown menu item which doesn't expose
|
||||
// its trigger to the click handler — so we capture the previously focused
|
||||
// element here, on mount, after the dropdown has restored focus to its
|
||||
// trigger button. `restoreFocus()` is then called by the parent on close.
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
addLastFocus(document.activeElement as HTMLElement | null);
|
||||
}, [addLastFocus]);
|
||||
|
||||
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]);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Box
|
||||
$css={overlayCss}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('Presenter mode')}
|
||||
>
|
||||
<Box $css={slideAreaCss}>
|
||||
<Box $css={slideFrameCss}>
|
||||
{mountedIndices.map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
$css={css`
|
||||
${slideWrapperCss};
|
||||
${i === currentIndex ? '' : 'display: 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,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
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';
|
||||
|
||||
interface PresenterSlideProps {
|
||||
blocks: unknown[];
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const slideCss = css`
|
||||
${cssEditor};
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
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"
|
||||
className="titi-presenter-slide"
|
||||
aria-label={ariaLabel ?? t('Presenter slide')}
|
||||
>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
editable={false}
|
||||
theme="light"
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
comments={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,89 @@
|
||||
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(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
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 (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
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 (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
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,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,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]);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PresenterOverlay } from './components/PresenterOverlay';
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user