From 841f4dd74a9e3f16030c24328aa8c92d24e57071 Mon Sep 17 00:00:00 2001 From: Mohamed El Amine BOUKERFA Date: Fri, 12 Jun 2026 17:11:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20keyboard=20shortcut?= =?UTF-8?q?=20to=20toggle=20presenter=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mod+Alt+P (Ctrl+Alt+P / Cmd+Option+P) now opens and closes the presenter from the document page. Signed-off-by: Mohamed El Amine BOUKERFA --- .../docs/doc-header/components/DocToolBox.tsx | 12 ++++- .../usePresenterToggleShortcut.spec.ts | 52 +++++++++++++++++++ .../hooks/usePresenterToggleShortcut.ts | 36 +++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterToggleShortcut.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterToggleShortcut.ts diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index d052dcb101..371bf7becf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -7,7 +7,7 @@ import { import { Present } from '@gouvfr-lasuite/ui-kit/icons'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg'; @@ -32,6 +32,7 @@ import { useDocUtils, useDuplicateDoc, } from '@/docs/doc-management'; +import { usePresenterToggleShortcut } from '@/docs/doc-presenter/hooks/usePresenterToggleShortcut'; import { useAuth } from '@/features/auth'; import { useFocusStore, useResponsiveStore } from '@/stores'; @@ -113,6 +114,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { restoreFocus, addLastFocus } = useFocusStore(); const { isMobile } = useResponsiveStore(); const copyDocLink = useCopyDocLink(doc.id); + + const togglePresenter = useCallback(() => { + setIsPresenterOpen((isOpen) => !isOpen); + }, []); + usePresenterToggleShortcut( + togglePresenter, + !doc.deleted_at && !isMobile, // same conditions as the "Present" menu entry + ); + const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: (data) => { void router.push(`/docs/${data.id}`); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterToggleShortcut.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterToggleShortcut.spec.ts new file mode 100644 index 0000000000..e55b94210c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterToggleShortcut.spec.ts @@ -0,0 +1,52 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { usePresenterToggleShortcut } from '../hooks/usePresenterToggleShortcut'; + +const renderShortcut = (enabled?: boolean) => { + const onToggle = vi.fn(); + renderHook(() => usePresenterToggleShortcut(onToggle, enabled)); + return onToggle; +}; + +const press = (init: KeyboardEventInit) => { + const event = new KeyboardEvent('keydown', { ...init, cancelable: true }); + window.dispatchEvent(event); + return event; +}; + +describe('usePresenterToggleShortcut', () => { + test('Ctrl+Alt+P and Meta+Alt+P call onToggle', () => { + const onToggle = renderShortcut(); + press({ code: 'KeyP', ctrlKey: true, altKey: true }); + press({ code: 'KeyP', metaKey: true, altKey: true }); + expect(onToggle).toHaveBeenCalledTimes(2); + }); + + test('Mod+Alt+P prevents default', () => { + renderShortcut(); + const event = press({ code: 'KeyP', ctrlKey: true, altKey: true }); + expect(event.defaultPrevented).toBe(true); + }); + + test('KeyP without the full modifier combination is ignored', () => { + const onToggle = renderShortcut(); + press({ code: 'KeyP' }); + press({ code: 'KeyP', ctrlKey: true }); + press({ code: 'KeyP', altKey: true }); + press({ code: 'KeyP', ctrlKey: true, altKey: true, shiftKey: true }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + test('repeat events are ignored', () => { + const onToggle = renderShortcut(); + press({ code: 'KeyP', ctrlKey: true, altKey: true, repeat: true }); + expect(onToggle).not.toHaveBeenCalled(); + }); + + test('does nothing when disabled', () => { + const onToggle = renderShortcut(false); + press({ code: 'KeyP', ctrlKey: true, altKey: true }); + expect(onToggle).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterToggleShortcut.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterToggleShortcut.ts new file mode 100644 index 0000000000..0d246527b5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/hooks/usePresenterToggleShortcut.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +/** + * Toggles the presenter on Mod+Alt+P (Ctrl+Alt+P / ⌘⌥P). + * Matching on `event.code` keeps the shortcut independent of the + * keyboard layout, and requiring Ctrl/Meta makes it safe to fire + * while typing in the editor. + */ +export const usePresenterToggleShortcut = ( + onToggle: () => void, + enabled = true, +) => { + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.code === 'KeyP' && + (event.ctrlKey || event.metaKey) && + event.altKey && + !event.shiftKey && + !event.repeat + ) { + event.preventDefault(); + onToggle(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [onToggle, enabled]); +};