diff --git a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts index 19f21b020..aaa343b29 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/presenter-mode.spec.ts @@ -286,15 +286,13 @@ test.describe('Presenter Mode', () => { await expect(overlay.getByText('Slide two')).toBeVisible(); }); - test('navigates between slides via keyboard shortcuts', async ({ - page, - browserName, - }) => { + test('navigates via keyboard shortcuts', async ({ page, browserName }) => { await createDoc(page, 'presenter-nav-keyboard', browserName, 1); await writeMultiSlideDoc(page); - const overlay = await openPresenter(page); + await page.keyboard.press('Control+Alt+KeyP'); + const overlay = page.getByRole('dialog', { name: 'Presenter mode' }); await expect(overlay.getByText('1 / 4')).toBeVisible(); await page.keyboard.press('ArrowRight'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useOpenPresenterShortcut.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useOpenPresenterShortcut.spec.ts new file mode 100644 index 000000000..61de2cd15 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useOpenPresenterShortcut.spec.ts @@ -0,0 +1,80 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { useOpenPresenterShortcut } from '../hooks/useOpenPresenterShortcut'; +import { usePresenterStore } from '../stores'; + +const press = (init: KeyboardEventInit) => { + const event = new KeyboardEvent('keydown', { ...init, cancelable: true }); + document.dispatchEvent(event); + return event; +}; + +describe('useOpenPresenterShortcut', () => { + afterEach(() => { + usePresenterStore.setState({ isOpen: false, initialSlideIndex: 0 }); + }); + + test('Ctrl+Alt+P opens the presenter on the first slide', () => { + renderHook(() => useOpenPresenterShortcut(true, () => {})); + + const event = press({ code: 'KeyP', ctrlKey: true, altKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(true); + expect(usePresenterStore.getState().initialSlideIndex).toBe(0); + expect(event.defaultPrevented).toBe(true); + }); + + test('Cmd+Alt+P opens the presenter', () => { + renderHook(() => useOpenPresenterShortcut(true, () => {})); + + press({ code: 'KeyP', metaKey: true, altKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(true); + }); + + test('leaves Ctrl+P to the browser', () => { + renderHook(() => useOpenPresenterShortcut(true, () => {})); + + const event = press({ code: 'KeyP', ctrlKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); + + test('ignores the shortcut when Shift is held', () => { + renderHook(() => useOpenPresenterShortcut(true, () => {})); + + press({ code: 'KeyP', ctrlKey: true, altKey: true, shiftKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(false); + }); + + test('does nothing when disabled', () => { + renderHook(() => useOpenPresenterShortcut(false, () => {})); + + press({ code: 'KeyP', ctrlKey: true, altKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(false); + }); + + test('keeps the current slide when the presenter is already open', () => { + usePresenterStore.setState({ isOpen: true, initialSlideIndex: 3 }); + renderHook(() => useOpenPresenterShortcut(true, () => {})); + + press({ code: 'KeyP', ctrlKey: true, altKey: true }); + + expect(usePresenterStore.getState().initialSlideIndex).toBe(3); + }); + + test('stops listening after unmount', () => { + const { unmount } = renderHook(() => + useOpenPresenterShortcut(true, () => {}), + ); + + unmount(); + press({ code: 'KeyP', ctrlKey: true, altKey: true }); + + expect(usePresenterStore.getState().isOpen).toBe(false); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx index 2d1931665..df2eca816 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/components/PresenterRoot.tsx @@ -8,6 +8,7 @@ import { useEditorStore } from '@/docs/doc-editor/stores'; import { useDocStore } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; +import { useOpenPresenterShortcut } from '../hooks/useOpenPresenterShortcut'; import { usePresenterStore } from '../stores'; const coverCss = css` @@ -92,19 +93,7 @@ export const PresenterRoot = () => { const active = !isMobile && (isOpen || wantsPresent); const isBooting = active && (!editor || !currentDoc); - // Let users escape the boot cover if the editor never finishes loading. - useEffect(() => { - if (!isBooting) { - return; - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - handleClose(); - } - }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [isBooting, handleClose]); + useOpenPresenterShortcut(!isBooting, handleClose); if (!active) { return null; diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useOpenPresenterShortcut.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useOpenPresenterShortcut.ts new file mode 100644 index 000000000..3063a10c2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/useOpenPresenterShortcut.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; + +import { usePresenterStore } from '../stores'; + +/** + * Binds Ctrl+Alt+P (Cmd+Alt+P on macOS) to open the current document in + * presentation mode, saving a trip through the doc options menu. + */ +export const useOpenPresenterShortcut = ( + enabled: boolean, + handleClose: () => void, +) => { + const open = usePresenterStore((state) => state.open); + const isOpen = usePresenterStore((state) => state.isOpen); + + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + const isPresentShortcut = + (event.ctrlKey || event.metaKey) && + event.altKey && + !event.shiftKey && + // On macOS, Alt+P types "π", so `event.key` cannot be used here. + event.code === 'KeyP'; + + if (!isPresentShortcut) { + return; + } + + event.preventDefault(); + + // Pressing the shortcut again while presenting must not jump back + // to the first slide. + if (!isOpen) { + open(0); + } + }; + + // Capture phase, so the editor's own key handlers cannot swallow it. + document.addEventListener('keydown', handleKeyDown, true); + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [enabled, isOpen, open]); + + // Let users escape the boot cover if the editor never finishes loading. + useEffect(() => { + if (!enabled) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [enabled, handleClose]); +};