mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-22 09:35:08 +02:00
✨(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:
@@ -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');
|
||||
|
||||
+80
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
-13
@@ -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;
|
||||
|
||||
+63
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user