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==}