diff --git a/.changeset/long-suns-smoke.md b/.changeset/long-suns-smoke.md new file mode 100644 index 000000000..e3d3c30fb --- /dev/null +++ b/.changeset/long-suns-smoke.md @@ -0,0 +1,5 @@ +--- +'spectacle': minor +--- + +Utilize `Kbar` to allow users to quickly search and use the current commands Spectacle supports within presentations. Fixes #1115 diff --git a/.npmrc b/.npmrc index 2eb073230..a01875662 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ +strict-peer-dependencies=false prefer-workspace-packages=true diff --git a/packages/spectacle/package.json b/packages/spectacle/package.json index daaef5acc..e5b3b2855 100644 --- a/packages/spectacle/package.json +++ b/packages/spectacle/package.json @@ -40,6 +40,7 @@ "broadcastchannel-polyfill": "^1.0.0", "dedent": "^0.7.0", "history": "^4.9.0", + "kbar": "0.1.0-beta.36", "mdast-builder": "^1.1.1", "mdast-zone": "^4.0.0", "merge-anything": "^3.0.3", diff --git a/packages/spectacle/src/components/command-bar/command-bar-actions.tsx b/packages/spectacle/src/components/command-bar/command-bar-actions.tsx new file mode 100644 index 000000000..de0ccdc25 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/command-bar-actions.tsx @@ -0,0 +1,72 @@ +import { + KEYBOARD_SHORTCUTS_IDS, + SpectacleMode, + SPECTACLE_MODES +} from '../../utils/constants'; +import useModes from '../../hooks/use-modes'; + +/** + * Kbar default actions, those that do not depend on dynamic logic, can be added here. + * To register actions dynamically use 'useRegisterActions' and make sure the action + * is registed within the KBarProvider. + * @see https://kbar.vercel.app/docs/concepts/actions + * Kbar action shortcuts dont seem to support all keybindings. If you need to utilize + * keybindings that are not supported you'll have to implement the keybinding seperately. + * @see useMousetrap + * To display keybindings that are not supported in the Kbar results, please use + * KEYBOARD_SHORTCUTS instead of Kbar actions 'shortcut' property. + * @see CommandBarResults getShortcutKeys + */ + +const spectacleModeDisplay = { + [SPECTACLE_MODES.DEFAULT_MODE]: 'Default Mode', + [SPECTACLE_MODES.PRESENTER_MODE]: 'Presenter Mode', + [SPECTACLE_MODES.OVERVIEW_MODE]: 'Overview Mode', + [SPECTACLE_MODES.PRINT_MODE]: 'Print Mode', + [SPECTACLE_MODES.EXPORT_MODE]: 'Export Mode' +}; + +const getName = (currentMode: string, mode: SpectacleMode) => { + const defaultMode = SPECTACLE_MODES.DEFAULT_MODE; + + return currentMode === mode + ? `← Back to ${spectacleModeDisplay[defaultMode]}` + : spectacleModeDisplay[mode]; +}; + +const useCommandBarActions = () => { + const { toggleMode, getCurrentMode } = useModes(); + const currentMode = getCurrentMode(); + return [ + { + id: KEYBOARD_SHORTCUTS_IDS.PRESENTER_MODE, + name: getName(currentMode, SPECTACLE_MODES.PRESENTER_MODE), + keywords: 'presenter', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.PRESENTER_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.OVERVIEW_MODE, + name: getName(currentMode, SPECTACLE_MODES.OVERVIEW_MODE), + keywords: 'overview', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.OVERVIEW_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.PRINT_MODE, + name: getName(currentMode, SPECTACLE_MODES.PRINT_MODE), + keywords: 'export', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.PRINT_MODE }), + section: 'Mode' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.EXPORT_MODE, + name: getName(currentMode, SPECTACLE_MODES.EXPORT_MODE), + keywords: 'export', + perform: () => toggleMode({ newMode: SPECTACLE_MODES.EXPORT_MODE }), + section: 'Mode' + } + ]; +}; + +export default useCommandBarActions; diff --git a/packages/spectacle/src/components/command-bar/index.tsx b/packages/spectacle/src/components/command-bar/index.tsx new file mode 100644 index 000000000..a4fe83f28 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/index.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; +import { KBarProvider } from 'kbar'; +import useCommandBarActions from './command-bar-actions'; +import CommandBarSearch from './search'; + +const CommandBar = ({ children }: CommandBarProps): JSX.Element => { + const actions = useCommandBarActions(); + return ( + + + {children} + + ); +}; + +export type CommandBarProps = { + children: ReactNode; +}; + +export default CommandBar; diff --git a/packages/spectacle/src/components/command-bar/results/index.tsx b/packages/spectacle/src/components/command-bar/results/index.tsx new file mode 100644 index 000000000..d2a6e3fc5 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/results/index.tsx @@ -0,0 +1,90 @@ +import styled from 'styled-components'; +import { ActionImpl, KBarResults, useMatches } from 'kbar'; +import { prettifyShortcut } from '../../../utils/platform-keys'; +import { + KeyboardShortcutTypes, + KEYBOARD_SHORTCUTS, + SYSTEM_FONT +} from '../../../utils/constants'; +import { Text } from '../../typography'; + +type RenderParams = { + item: ActionImpl | string; + active: boolean; +}; + +function getShortcutKeys({ id, shortcut = [] }: ActionImpl): string[] { + if (id in KEYBOARD_SHORTCUTS && !shortcut?.length) { + const _id = id as KeyboardShortcutTypes; + return prettifyShortcut(KEYBOARD_SHORTCUTS[_id]); + } + return prettifyShortcut(shortcut); +} + +const ResultCommand = styled.div>` + display: flex; + justify-content: space-between; + align-items: center; + background-color: ${(p) => (p.active ? 'lightsteelblue' : 'transparent')}; + padding: 0.5rem 1rem; + cursor: pointer; + height: 30px; +`; + +const ResultSectionHeader = styled(Text)` + background-color: white; + color: gray; + margin: 0 2rem; + padding: 0.5rem 0; + font-size: small; + font-weight: bold; + font-family: ${SYSTEM_FONT}; +`; + +const ResultShortcut = styled.span` + display: flex; + gap: 5px; +`; + +const ResultShortcutKey = styled.kbd` + display: flex; + justify-content: center; + align-items: center; + background-color: #eee; + border-radius: 5px; + border: 1px solid #b4b4b4; + padding: 5px 10px; + min-width: 20px; + height: 25px; + white-space: nowrap; + font-family: ${SYSTEM_FONT}; +`; + +function onRender({ item, active }: RenderParams) { + if (typeof item === 'string') { + return {item}; + } else { + return ( + + {item.name} + + {getShortcutKeys(item).map( + (key) => + key && ( + + {key} + + ) + )} + + + ); + } +} + +const CommandBarResults = () => { + const { results } = useMatches(); + return ; +}; + +export default CommandBarResults; diff --git a/packages/spectacle/src/components/command-bar/search/index.tsx b/packages/spectacle/src/components/command-bar/search/index.tsx new file mode 100644 index 000000000..0dccbda77 --- /dev/null +++ b/packages/spectacle/src/components/command-bar/search/index.tsx @@ -0,0 +1,36 @@ +import styled from 'styled-components'; +import { KBarPortal, KBarPositioner, KBarAnimator, KBarSearch } from 'kbar'; +import CommandBarResults from '../results'; + +const KBarSearchStyled = styled(KBarSearch)` + padding: 12px 16px; + font-size: 16px; + width: 100%; + box-sizing: border-box; + outline: none; + border: none; +`; + +const KBarAnimatorStyled = styled(KBarAnimator)` + max-width: 600px; + width: 100%; + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px; +`; + +const CommandBarSearch = () => { + return ( + + + + + + + + + ); +}; + +export default CommandBarSearch; diff --git a/packages/spectacle/src/components/deck/deck.tsx b/packages/spectacle/src/components/deck/deck.tsx index 62e29efd2..7f7d7aee5 100644 --- a/packages/spectacle/src/components/deck/deck.tsx +++ b/packages/spectacle/src/components/deck/deck.tsx @@ -38,6 +38,8 @@ import { defaultTransition, SlideTransition } from '../transitions'; import { SwipeEventData } from 'react-swipeable'; import { MarkdownComponentMap } from '../../utils/mdx-component-mapper'; import TemplateWrapper from '../template-wrapper'; +import { useRegisterActions } from 'kbar'; +import { KEYBOARD_SHORTCUTS_IDS } from '../../utils/constants'; export type DeckContextType = { deckId: string | number; @@ -66,6 +68,7 @@ export type DeckContextType = { }; skipTo(options: { slideIndex: number; stepIndex: number }): void; stepForward(): void; + stepBackward(): void; advanceSlide(): void; regressSlide(): void; commitTransition(newView?: { stepIndex: number }): void; @@ -217,6 +220,37 @@ export const DeckInternal = forwardRef( ] ); + useRegisterActions( + !disableInteractivity + ? [ + { + id: KEYBOARD_SHORTCUTS_IDS.NEXT_SLIDE, + name: 'Next Slide', + keywords: 'next', + perform: () => stepForward(), + section: 'Slide' + }, + { + id: KEYBOARD_SHORTCUTS_IDS.PREVIOUS_SLIDE, + name: 'Previous Slide', + keywords: 'previous', + perform: () => stepBackward(), + section: 'Slide' + }, + { + id: 'Restart Presentation', + name: 'Restart Presentation', + keywords: 'restart', + perform: () => + skipTo({ + slideIndex: 0, + stepIndex: 0 + }), + section: 'Slide' + } + ] + : [] + ); useMousetrap( disableInteractivity ? {} @@ -441,6 +475,7 @@ export const DeckInternal = forwardRef( }, skipTo, stepForward, + stepBackward, advanceSlide, regressSlide, commitTransition, diff --git a/packages/spectacle/src/components/deck/default-deck.tsx b/packages/spectacle/src/components/deck/default-deck.tsx index 235f1f43b..2519d0add 100644 --- a/packages/spectacle/src/components/deck/default-deck.tsx +++ b/packages/spectacle/src/components/deck/default-deck.tsx @@ -5,7 +5,7 @@ import useMousetrap from '../../hooks/use-mousetrap'; import { KEYBOARD_SHORTCUTS, SPECTACLE_MODES, - SpectacleMode + ToggleModeParams } from '../../utils/constants'; /** @@ -51,7 +51,9 @@ const DefaultDeck = (props: DefaultDeckProps): JSX.Element => { stepIndex: 0 }), [KEYBOARD_SHORTCUTS.SELECT_SLIDE_OVERVIEW_MODE]: (e) => - toggleMode(e, SPECTACLE_MODES.DEFAULT_MODE) + toggleMode({ + newMode: SPECTACLE_MODES.DEFAULT_MODE + }) } : {}, [] @@ -62,7 +64,11 @@ const DefaultDeck = (props: DefaultDeckProps): JSX.Element => { >( (e, slideIndex) => { if (overviewMode) { - toggleMode(e, SPECTACLE_MODES.DEFAULT_MODE, +slideIndex); + toggleMode({ + e, + newMode: SPECTACLE_MODES.DEFAULT_MODE, + senderSlideIndex: +slideIndex + }); } }, [overviewMode, toggleMode] @@ -98,11 +104,7 @@ const DefaultDeck = (props: DefaultDeckProps): JSX.Element => { export default DefaultDeck; type DefaultDeckProps = DeckProps & { - toggleMode( - e: unknown, - newMode: SpectacleMode, - senderSlideIndex?: number - ): void; + toggleMode(args: ToggleModeParams): void; overviewMode?: boolean; printMode?: boolean; exportMode?: boolean; diff --git a/packages/spectacle/src/components/deck/index.tsx b/packages/spectacle/src/components/deck/index.tsx index 6a1e41ce3..76559df0f 100644 --- a/packages/spectacle/src/components/deck/index.tsx +++ b/packages/spectacle/src/components/deck/index.tsx @@ -1,75 +1,20 @@ -import { Fragment, useCallback, useRef } from 'react'; -import { parse as parseQS, stringify as stringifyQS } from 'query-string'; +import { Fragment } from 'react'; import DefaultDeck from './default-deck'; import PresenterMode from '../presenter-mode'; import PrintMode from '../print-mode'; import useMousetrap from '../../hooks/use-mousetrap'; -import { - KEYBOARD_SHORTCUTS, - SPECTACLE_MODES, - SpectacleMode -} from '../../utils/constants'; -import { modeKeyForSearchParam, modeSearchParamForKey } from './modes'; +import { KEYBOARD_SHORTCUTS, SPECTACLE_MODES } from '../../utils/constants'; import { DeckProps } from './deck'; +import useModes, { ModeActions } from '../../hooks/use-modes'; +import CommandBar from '../command-bar'; -const SpectacleDeck = (props: DeckProps): JSX.Element => { - const mode = useRef( - modeKeyForSearchParam( - parseQS(location.search, { - parseBooleans: true - }) - ) - ); - - const toggleMode = useCallback( - (e: Event, newMode: SpectacleMode, senderSlideIndex?: number) => { - e?.preventDefault(); - - let stepIndex: string | number = 0; - let slideIndex: string | number = senderSlideIndex || ''; - const searchParams = parseQS(location.search, { - parseBooleans: true - }); - - if (!slideIndex) { - slideIndex = searchParams.slideIndex as string; - stepIndex = searchParams.stepIndex as string; - } - - if (mode.current === newMode) { - location.search = stringifyQS({ - slideIndex, - stepIndex - }); - return; - } - - mode.current = newMode; - - location.search = stringifyQS({ - slideIndex, - stepIndex, - ...modeSearchParamForKey(newMode) - }); - }, - [mode] - ); - - useMousetrap( - { - [KEYBOARD_SHORTCUTS.PRESENTER_MODE]: (e) => - e && toggleMode(e, SPECTACLE_MODES.PRESENTER_MODE), - [KEYBOARD_SHORTCUTS.PRINT_MODE]: (e) => - e && toggleMode(e, SPECTACLE_MODES.PRINT_MODE), - [KEYBOARD_SHORTCUTS.EXPORT_MODE]: (e) => - e && toggleMode(e, SPECTACLE_MODES.EXPORT_MODE), - [KEYBOARD_SHORTCUTS.OVERVIEW_MODE]: (e) => - e && toggleMode(e, SPECTACLE_MODES.OVERVIEW_MODE) - }, - [] - ); - - switch (mode.current) { +const View = ({ + getCurrentMode, + toggleMode, + ...props +}: ModeActions & DeckProps) => { + const mode = getCurrentMode(); + switch (mode) { case SPECTACLE_MODES.DEFAULT_MODE: return ; @@ -95,4 +40,32 @@ const SpectacleDeck = (props: DeckProps): JSX.Element => { } }; +const SpectacleDeck = (props: DeckProps): JSX.Element => { + const { toggleMode, getCurrentMode } = useModes(); + + useMousetrap( + { + [KEYBOARD_SHORTCUTS.PRESENTER_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.PRESENTER_MODE }), + [KEYBOARD_SHORTCUTS.PRINT_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.PRINT_MODE }), + [KEYBOARD_SHORTCUTS.EXPORT_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.EXPORT_MODE }), + [KEYBOARD_SHORTCUTS.OVERVIEW_MODE]: (e) => + e && toggleMode({ e, newMode: SPECTACLE_MODES.OVERVIEW_MODE }) + }, + [] + ); + + return ( + + + + ); +}; + export default SpectacleDeck; diff --git a/packages/spectacle/src/components/deck/modes.ts b/packages/spectacle/src/components/deck/modes.ts deleted file mode 100644 index 6665b31fb..000000000 --- a/packages/spectacle/src/components/deck/modes.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SPECTACLE_MODES, SpectacleMode } from '../../utils/constants'; - -type ModeSearchParams = { - presenterMode?: boolean; - overviewMode?: boolean; - printMode?: boolean; - exportMode?: boolean; -}; - -export function modeSearchParamForKey(key: SpectacleMode): ModeSearchParams { - if (key === SPECTACLE_MODES.PRESENTER_MODE) { - return { presenterMode: true }; - } else if (key === SPECTACLE_MODES.OVERVIEW_MODE) { - return { overviewMode: true }; - } else if (key === SPECTACLE_MODES.PRINT_MODE) { - return { printMode: true }; - } else if (key === SPECTACLE_MODES.EXPORT_MODE) { - return { exportMode: true }; - } - return {}; -} - -export function modeKeyForSearchParam({ - presenterMode, - overviewMode, - printMode, - exportMode -}: ModeSearchParams) { - if (presenterMode) { - return SPECTACLE_MODES.PRESENTER_MODE; - } else if (overviewMode) { - return SPECTACLE_MODES.OVERVIEW_MODE; - } else if (printMode) { - return SPECTACLE_MODES.PRINT_MODE; - } else if (exportMode) { - return SPECTACLE_MODES.EXPORT_MODE; - } - return SPECTACLE_MODES.DEFAULT_MODE; -} diff --git a/packages/spectacle/src/components/markdown/markdown.test.tsx b/packages/spectacle/src/components/markdown/markdown.test.tsx index 0b7f37d50..2b2274c2b 100644 --- a/packages/spectacle/src/components/markdown/markdown.test.tsx +++ b/packages/spectacle/src/components/markdown/markdown.test.tsx @@ -1,10 +1,18 @@ import { ReactElement } from 'react'; import { Markdown, MarkdownSlide, MarkdownSlideSet } from './markdown'; -import Deck from '../deck/deck'; -import { Heading, ListItem } from '../typography'; -import { Appear } from '../appear'; +import Deck from '../deck'; +import { Heading } from '../typography'; import Slide from '../slide/slide'; -import { queryByTestId, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; + +jest.mock('../../hooks/use-broadcast-channel', () => { + return { + __esModule: true, + default: function useBroadcastChannel() { + return [() => {}]; + } + }; +}); const mountInsideDeck = (tree: ReactElement) => { return render({tree}); diff --git a/packages/spectacle/src/components/presenter-mode/timer.tsx b/packages/spectacle/src/components/presenter-mode/timer.tsx index 36cb77d2f..1b972c626 100644 --- a/packages/spectacle/src/components/presenter-mode/timer.tsx +++ b/packages/spectacle/src/components/presenter-mode/timer.tsx @@ -4,13 +4,34 @@ import { FlexBox, Box } from '../layout-primitives'; import InternalButton from '../internal-button'; import { useTimer } from '../../utils/use-timer'; import { SYSTEM_FONT } from '../../utils/constants'; +import { useRegisterActions } from 'kbar'; export const Timer = () => { const [timer, setTimer] = useState(0); const [timerStarted, setTimerStarted] = useState(false); const addToTimer = useCallback((v: number) => setTimer((s) => s + v), []); + const toggleTimer = useCallback(() => setTimerStarted((s) => !s), []); + const resetTimer = useCallback(() => setTimer(0), []); useTimer(addToTimer, 1000, timerStarted); const minutes = Math.floor(Math.round(timer) / 60); + + useRegisterActions([ + { + id: 'Start/Pause Timer', + name: 'Start/Pause Timer', + keywords: 'start pause', + perform: toggleTimer, + section: 'Timer' + }, + { + id: 'Restart Timer', + name: 'Restart Timer', + keywords: 'restart', + perform: resetTimer, + section: 'Timer' + } + ]); + return ( @@ -23,11 +44,11 @@ export const Timer = () => { Math.round(timer) - minutes * 60 ).padStart(2, '0')}`} - setTimerStarted((s) => !s)}> + {timerStarted ? 'Stop Timer' : 'Start Timer'} - setTimer(0)}>Reset + Reset ); }; diff --git a/packages/spectacle/src/components/slide-layout.test.tsx b/packages/spectacle/src/components/slide-layout.test.tsx index a981bc9d5..b34f971dd 100644 --- a/packages/spectacle/src/components/slide-layout.test.tsx +++ b/packages/spectacle/src/components/slide-layout.test.tsx @@ -1,9 +1,18 @@ import { ReactElement } from 'react'; import { render } from '@testing-library/react'; -import Deck from './deck/deck'; +import Deck from './deck'; import SlideLayout from './slide-layout'; import { Heading, Text } from './typography'; +jest.mock('../hooks/use-broadcast-channel', () => { + return { + __esModule: true, + default: function useBroadcastChannel() { + return [() => {}]; + } + }; +}); + const renderInDeck = (tree: ReactElement | JSX.Element) => render({tree}); diff --git a/packages/spectacle/src/hooks/use-modes.test.ts b/packages/spectacle/src/hooks/use-modes.test.ts new file mode 100644 index 000000000..291a0399e --- /dev/null +++ b/packages/spectacle/src/hooks/use-modes.test.ts @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { renderHook } from '@testing-library/react'; +import { SPECTACLE_MODES } from '../utils/constants'; +import useModes from './use-modes'; + +const { location: locationBefore } = window; + +describe('useModes', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { + search: 'slideIndex=0&stepIndex=0' + }, + writable: true + }); + }); + + afterAll(() => { + window.location = locationBefore; + }); + describe('toggleMode and currentMode', () => { + it('should set the window.location and current mode based on the spectacle modes', () => { + const { result } = renderHook(() => useModes()); + + // Default + expect(result.current.getCurrentMode()).toBe( + SPECTACLE_MODES.DEFAULT_MODE + ); + + // Presenter + result.current.toggleMode({ newMode: SPECTACLE_MODES.PRESENTER_MODE }); + expect(location.search).toMatch(/^presenterMode=true/); + expect(result.current.getCurrentMode()).toBe( + SPECTACLE_MODES.PRESENTER_MODE + ); + + // Overview + result.current.toggleMode({ newMode: SPECTACLE_MODES.OVERVIEW_MODE }); + expect(location.search).toMatch(/^overviewMode/); + expect(result.current.getCurrentMode()).toBe( + SPECTACLE_MODES.OVERVIEW_MODE + ); + + // Print + result.current.toggleMode({ newMode: SPECTACLE_MODES.PRINT_MODE }); + expect(location.search).toMatch(/^printMode=true/); + expect(result.current.getCurrentMode()).toBe(SPECTACLE_MODES.PRINT_MODE); + + // Export + result.current.toggleMode({ newMode: SPECTACLE_MODES.EXPORT_MODE }); + expect(location.search).toMatch(/^exportMode=true/); + expect(result.current.getCurrentMode()).toBe(SPECTACLE_MODES.EXPORT_MODE); + }); + }); +}); diff --git a/packages/spectacle/src/hooks/use-modes.ts b/packages/spectacle/src/hooks/use-modes.ts new file mode 100644 index 000000000..0238bce3b --- /dev/null +++ b/packages/spectacle/src/hooks/use-modes.ts @@ -0,0 +1,95 @@ +import { useCallback, useRef } from 'react'; +import { parse as parseQS, stringify as stringifyQS } from 'query-string'; +import { + SPECTACLE_MODES, + SpectacleMode, + ToggleModeParams, + ModeSearchParams +} from '../utils/constants'; + +const useModes = (): ModeActions => { + const mode = useRef( + modeKeyForSearchParam( + parseQS(window.location.search, { + parseBooleans: true + }) + ) + ); + + const toggleMode = useCallback( + (args: ToggleModeParams) => { + const { newMode, senderSlideIndex, e } = args; + e?.preventDefault(); + + let stepIndex: string | number = 0; + let slideIndex: string | number = senderSlideIndex || ''; + const searchParams = parseQS(window.location.search, { + parseBooleans: true + }); + + if (!slideIndex) { + slideIndex = searchParams.slideIndex as string; + stepIndex = searchParams.stepIndex as string; + } + + if (mode.current === newMode) { + window.location.search = stringifyQS({ + slideIndex, + stepIndex + }); + return; + } + + mode.current = newMode; + + window.location.search = stringifyQS({ + slideIndex, + stepIndex, + ...modeSearchParamForKey(newMode) + }); + }, + [mode] + ); + + const getCurrentMode = useCallback((): SpectacleMode => mode.current, []); + + return { toggleMode, getCurrentMode }; +}; + +function modeSearchParamForKey(key: SpectacleMode): ModeSearchParams { + if (key === SPECTACLE_MODES.PRESENTER_MODE) { + return { presenterMode: true }; + } else if (key === SPECTACLE_MODES.OVERVIEW_MODE) { + return { overviewMode: true }; + } else if (key === SPECTACLE_MODES.PRINT_MODE) { + return { printMode: true }; + } else if (key === SPECTACLE_MODES.EXPORT_MODE) { + return { exportMode: true }; + } + return {}; +} + +function modeKeyForSearchParam({ + presenterMode, + overviewMode, + printMode, + exportMode +}: ModeSearchParams) { + if (presenterMode) { + return SPECTACLE_MODES.PRESENTER_MODE; + } else if (overviewMode) { + return SPECTACLE_MODES.OVERVIEW_MODE; + } else if (printMode) { + return SPECTACLE_MODES.PRINT_MODE; + } else if (exportMode) { + return SPECTACLE_MODES.EXPORT_MODE; + } + return SPECTACLE_MODES.DEFAULT_MODE; +} + +export type ModeActions = { + toggleMode: (args: ToggleModeParams) => void; + getCurrentMode: () => SpectacleMode; +}; + +export default useModes; diff --git a/packages/spectacle/src/index.ts b/packages/spectacle/src/index.ts index 53dd646c8..777297036 100644 --- a/packages/spectacle/src/index.ts +++ b/packages/spectacle/src/index.ts @@ -19,6 +19,7 @@ export { TableHeader, TableBody } from './components/table'; +export { default as CommandBar } from './components/command-bar'; export type { TableProps } from './components/table'; export { FlexBox, Grid, Box } from './components/layout-primitives'; export { Image, FullSizeImage } from './components/image'; diff --git a/packages/spectacle/src/utils/constants.ts b/packages/spectacle/src/utils/constants.ts index 195a1bd24..f5c05562d 100644 --- a/packages/spectacle/src/utils/constants.ts +++ b/packages/spectacle/src/utils/constants.ts @@ -10,7 +10,22 @@ export const KEYBOARD_SHORTCUTS = { EXPORT_MODE: 'mod+shift+e', TAB_FORWARD_OVERVIEW_MODE: 'tab', TAB_BACKWARD_OVERVIEW_MODE: 'shift+tab', - SELECT_SLIDE_OVERVIEW_MODE: 'enter' + SELECT_SLIDE_OVERVIEW_MODE: 'enter', + NEXT_SLIDE: 'right', + PREVIOUS_SLIDE: 'left' +}; +export type KeyboardShortcutTypes = keyof typeof KEYBOARD_SHORTCUTS; + +export const KEYBOARD_SHORTCUTS_IDS = { + PRESENTER_MODE: 'PRESENTER_MODE', + OVERVIEW_MODE: 'OVERVIEW_MODE', + PRINT_MODE: 'PRINT_MODE', + EXPORT_MODE: 'EXPORT_MODE', + TAB_FORWARD_OVERVIEW_MODE: 'TAB_FORWARD_OVERVIEW_MODE', + TAB_BACKWARD_OVERVIEW_MODE: 'TAB_BACKWARD_OVERVIEW_MODE', + SELECT_SLIDE_OVERVIEW_MODE: 'SELECT_SLIDE_OVERVIEW_MODE', + NEXT_SLIDE: 'NEXT_SLIDE', + PREVIOUS_SLIDE: 'PREVIOUS_SLIDE' }; export const SPECTACLE_MODES = { @@ -22,3 +37,16 @@ export const SPECTACLE_MODES = { } as const; type ValuesOf = T[keyof T]; export type SpectacleMode = ValuesOf; + +export type ModeSearchParams = { + presenterMode?: boolean; + overviewMode?: boolean; + printMode?: boolean; + exportMode?: boolean; +}; + +export type ToggleModeParams = { + newMode: SpectacleMode; + senderSlideIndex?: number; + e?: Event; +}; diff --git a/packages/spectacle/src/utils/platform-keys.test.ts b/packages/spectacle/src/utils/platform-keys.test.ts new file mode 100644 index 000000000..4dadffa81 --- /dev/null +++ b/packages/spectacle/src/utils/platform-keys.test.ts @@ -0,0 +1,98 @@ +import { + isPlatformMacOS, + getKeyForOS, + prettifyShortcut +} from './platform-keys'; + +const { navigator: navigatorBefore } = window; + +describe('platform-keys', () => { + beforeAll(() => { + Object.defineProperty(window, 'navigator', { + writable: true + }); + }); + + afterAll(() => { + window.navigator = navigatorBefore; + }); + describe('isPlatformMacOS', () => { + it('should return true for MacIntel', () => { + Object.defineProperty(window, 'navigator', { + value: { + userAgent: 'MacIntel' + } + }); + + expect(isPlatformMacOS()).toBe(true); + }); + + it('Return false for anything that doesnt contain Mac|darwin|iPad', () => { + Object.defineProperty(window, 'navigator', { + value: { + userAgent: 'Windows' + } + }); + expect(isPlatformMacOS()).toBe(false); + + Object.defineProperty(window, 'navigator', { + value: { + userAgent: 'Linux' + } + }); + expect(isPlatformMacOS()).toBe(false); + }); + }); + + describe('getKeyForOS', () => { + it.each` + userAgent | key | result + ${'MacIntel'} | ${'mod'} | ${'⌘'} + ${'Windows'} | ${'mod'} | ${'Ctrl'} + ${'MacIntel'} | ${'shift'} | ${'⇧'} + ${'Windows'} | ${'shift'} | ${'Shift'} + ${'MacIntel'} | ${'ctrl'} | ${'^'} + ${'Windows'} | ${'ctrl'} | ${'Ctrl'} + ${'MacIntel'} | ${'alt'} | ${'⌥'} + ${'Windows'} | ${'alt'} | ${'Alt'} + `( + 'should return $result for $key on $userAgent', + ({ userAgent, key, result }) => { + Object.defineProperty(navigator, 'userAgent', { + value: userAgent, + configurable: true + }); + + expect(getKeyForOS(key)).toStrictEqual(result); + } + ); + }); + + describe('prettifyShortcut', () => { + it.each` + userAgent | shortcut | result + ${'MacIntel'} | ${'mod+shift+p'} | ${['⌘', '⇧', 'P']} + ${'Windows'} | ${'mod+shift+p'} | ${['Ctrl', 'Shift', 'P']} + ${'MacIntel'} | ${'mod+shift+o'} | ${['⌘', '⇧', 'O']} + ${'Windows'} | ${'mod+shift+o'} | ${['Ctrl', 'Shift', 'O']} + ${'MacIntel'} | ${['mod', 'shift', 'r']} | ${['⌘', '⇧', 'R']} + ${'Windows'} | ${['mod', 'shift', 'r']} | ${['Ctrl', 'Shift', 'R']} + ${'MacIntel'} | ${['mod', 'shift', 'e']} | ${['⌘', '⇧', 'E']} + ${'Windows'} | ${['mod', 'shift', 'e']} | ${['Ctrl', 'Shift', 'E']} + ${'MacIntel'} | ${'left'} | ${['←']} + ${'Windows'} | ${'left'} | ${['←']} + ${'MacIntel'} | ${'right'} | ${['→']} + ${'Windows'} | ${'right'} | ${['→']} + `( + 'should return $result for $shortcut on $userAgent', + ({ userAgent, shortcut, result }) => { + Object.defineProperty(navigator, 'userAgent', { + value: userAgent, + configurable: true + }); + + expect(prettifyShortcut(shortcut)).toStrictEqual(result); + } + ); + }); +}); diff --git a/packages/spectacle/src/utils/platform-keys.ts b/packages/spectacle/src/utils/platform-keys.ts new file mode 100644 index 000000000..e5a5e87c4 --- /dev/null +++ b/packages/spectacle/src/utils/platform-keys.ts @@ -0,0 +1,42 @@ +/* + * Check if operating system is MacOS + */ +export function isPlatformMacOS() { + return /Mac|iPad/.test(navigator.userAgent); +} + +/* + * Get operating system specific key + */ +export function getKeyForOS(key: KeyType) { + const isMacOS = isPlatformMacOS(); + + const replacementKeyMap = { + alt: isMacOS ? '⌥' : 'Alt', + ctrl: isMacOS ? '^' : 'Ctrl', + mod: isMacOS ? '⌘' : 'Ctrl', + shift: isMacOS ? '⇧' : 'Shift' + }; + + return replacementKeyMap[key]; +} + +/** + * Prettifies keyboard shortcuts in a platform-agnostic way. + */ +export function prettifyShortcut(shortcut: string[] | string): string[] { + const _shortcut = + typeof shortcut === 'string' ? shortcut : shortcut.join('+'); + return _shortcut + .toLowerCase() + .replace('alt', getKeyForOS('alt')) + .replace('ctrl', getKeyForOS('ctrl')) + .replace('mod', getKeyForOS('mod')) + .replace('shift', getKeyForOS('shift')) + .replace('left', '←') + .replace('right', '→') + .split('+') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)); +} + +export type KeyType = 'alt' | 'ctrl' | 'mod' | 'shift'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec79d9bad..6d91a6900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,7 @@ importers: history: ^4.9.0 html-webpack-plugin: ^5.5.0 jest: ^27.3.1 + kbar: 0.1.0-beta.36 mdast-builder: ^1.1.1 mdast-zone: ^4.0.0 merge-anything: ^3.0.3 @@ -250,6 +251,7 @@ importers: broadcastchannel-polyfill: 1.0.1 dedent: 0.7.0 history: 4.10.1 + kbar: 0.1.0-beta.36_biqbaboplfbrettd7655fr4n2y mdast-builder: 1.1.1 mdast-zone: 4.0.1 merge-anything: 3.0.7 @@ -2169,6 +2171,35 @@ packages: fastq: 1.13.0 dev: true + /@reach/observe-rect/1.2.0: + resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==} + dev: false + + /@reach/portal/0.16.2_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-9ur/yxNkuVYTIjAcfi46LdKUvH0uYZPfEp4usWcpt6PIp+WDF57F/5deMe/uGi/B/nfDweQu8VVwuMVrCb97JQ==} + peerDependencies: + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x + dependencies: + '@reach/utils': 0.16.0_biqbaboplfbrettd7655fr4n2y + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + tiny-warning: 1.0.3 + tslib: 2.4.0 + dev: false + + /@reach/utils/0.16.0_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==} + peerDependencies: + react: ^16.8.0 || 17.x + react-dom: ^16.8.0 || 17.x + dependencies: + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + tiny-warning: 1.0.3 + tslib: 2.4.0 + dev: false + /@sinonjs/commons/1.8.3: resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} dependencies: @@ -3729,6 +3760,10 @@ packages: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} dev: false + /command-score/0.1.2: + resolution: {integrity: sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==} + dev: false + /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true @@ -4646,6 +4681,10 @@ packages: resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} dev: true + /fast-equals/2.0.4: + resolution: {integrity: sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==} + dev: false + /fast-glob/3.2.11: resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} engines: {node: '>=8.6.0'} @@ -6251,6 +6290,21 @@ packages: object.assign: 4.1.2 dev: true + /kbar/0.1.0-beta.36_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-i5tU7VYkMmxHCoyG5qzkNeU3qViKBz2F0fjqvWWSKsgVABCF3BjxzAH570Mhn3Zy92x3NGZae8emkBpEk7MKgw==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@reach/portal': 0.16.2_biqbaboplfbrettd7655fr4n2y + command-score: 0.1.2 + fast-equals: 2.0.4 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + react-virtual: 2.10.4_react@18.2.0 + tiny-invariant: 1.2.0 + dev: false + /kind-of/3.2.2: resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} engines: {node: '>=0.10.0'} @@ -7326,6 +7380,15 @@ packages: refractor: 3.6.0 dev: false + /react-virtual/2.10.4_react@18.2.0: + resolution: {integrity: sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==} + peerDependencies: + react: ^16.6.3 || ^17.0.0 + dependencies: + '@reach/observe-rect': 1.2.0 + react: 18.2.0 + dev: false + /react/18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -8457,7 +8520,6 @@ packages: /tslib/2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} - dev: true /tsutils/3.21.0_typescript@4.7.4: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}