(frontend) add keyboard shortcut to open presentation mode

Ctrl+Alt+P (Cmd+Option+P on macOS) opens the current document in
presentation mode, saving two clicks through the document menu.

Signed-off-by: Anveet Pal <anveetpal12@gmail.com>
This commit is contained in:
Anveet Pal
2026-09-21 14:16:29 +02:00
committed by Anthony LC
parent b303173923
commit 441ddfbd7e
4 changed files with 148 additions and 18 deletions
@@ -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');
@@ -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);
});
});
@@ -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;
@@ -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]);
};