import { navigator, document } from 'global'; import { PREVIEW_KEYDOWN } from '@storybook/core-events'; import { Module, API } from '../index'; import { shortcutMatchesShortcut, eventToShortcut } from '../lib/shortcut'; export const isMacLike = () => navigator && navigator.platform ? !!navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) : false; export const controlOrMetaKey = () => (isMacLike() ? 'meta' : 'control'); export function keys(o: O) { return Object.keys(o) as Array; } interface SubState { shortcuts: Shortcuts; } interface SubAPI { getShortcutKeys(): Shortcuts; setShortcuts(shortcuts: Shortcuts): Promise; setShortcut(action: Action, value: KeyCollection): Promise; restoreAllDefaultShortcuts(): Promise; restoreDefaultShortcut(action: Action): Promise; handleKeydownEvent(api: API, event: Event): void; handleShortcutFeature(api: API, feature: Action): void; } export type KeyCollection = string[]; export interface Shortcuts { fullScreen: KeyCollection; togglePanel: KeyCollection; panelPosition: KeyCollection; toggleNav: KeyCollection; toolbar: KeyCollection; search: KeyCollection; focusNav: KeyCollection; focusIframe: KeyCollection; focusPanel: KeyCollection; prevComponent: KeyCollection; nextComponent: KeyCollection; prevStory: KeyCollection; nextStory: KeyCollection; shortcutsPage: KeyCollection; aboutPage: KeyCollection; escape: KeyCollection; } export type Action = keyof Shortcuts; export const defaultShortcuts: Shortcuts = Object.freeze({ fullScreen: ['F'], togglePanel: ['S'], // Panel visibiliy panelPosition: ['D'], toggleNav: ['A'], toolbar: ['T'], search: ['/'], focusNav: ['1'], focusIframe: ['2'], focusPanel: ['3'], prevComponent: ['alt', 'ArrowUp'], nextComponent: ['alt', 'ArrowDown'], prevStory: ['alt', 'ArrowLeft'], nextStory: ['alt', 'ArrowRight'], shortcutsPage: [controlOrMetaKey(), 'shift', ','], aboutPage: [','], escape: ['escape'], // This one is not customizable }); export interface Event extends KeyboardEvent { target: { tagName: string; addEventListener(): void; removeEventListener(): boolean; dispatchEvent(event: Event): boolean; getAttribute(attr: string): string | null; }; } export default function initShortcuts({ store }: Module) { const api: SubAPI = { // Getting and setting shortcuts getShortcutKeys(): Shortcuts { return store.getState().shortcuts; }, async setShortcuts(shortcuts: Shortcuts) { await store.setState({ shortcuts }, { persistence: 'permanent' }); return shortcuts; }, async restoreAllDefaultShortcuts() { return api.setShortcuts(defaultShortcuts); }, async setShortcut(action, value) { const shortcuts = api.getShortcutKeys(); await api.setShortcuts({ ...shortcuts, [action]: value }); return value; }, async restoreDefaultShortcut(action) { const defaultShortcut = defaultShortcuts[action]; return api.setShortcut(action, defaultShortcut); }, // Listening to shortcut events handleKeydownEvent(fullApi, event) { const shortcut = eventToShortcut(event); const shortcuts = api.getShortcutKeys(); const actions = keys(shortcuts); const matchedFeature = actions.find((feature: Action) => shortcutMatchesShortcut(shortcut, shortcuts[feature]) ); if (matchedFeature) { api.handleShortcutFeature(fullApi, matchedFeature); } }, handleShortcutFeature(fullApi, feature) { const { layout: { isFullscreen, showNav, showPanel }, } = store.getState(); switch (feature) { case 'escape': { if (isFullscreen) { fullApi.toggleFullscreen(); } else if (!showNav) { fullApi.toggleNav(); } document.activeElement.blur(); break; } case 'focusNav': { if (isFullscreen) { fullApi.toggleFullscreen(); } if (!showNav) { fullApi.toggleNav(); } const element = document.getElementById('storybook-explorer-menu'); if (element) { element.focus(); } break; } case 'search': { if (isFullscreen) { fullApi.toggleFullscreen(); } if (!showNav) { fullApi.toggleNav(); } const element = document.getElementById('storybook-explorer-searchfield'); if (element) { element.focus(); } break; } case 'focusIframe': { const element = document.getElementById('storybook-preview-iframe'); if (element) { try { // should be like a channel message and all that, but yolo for now element.contentWindow.focus(); } catch (e) { // } } break; } case 'focusPanel': { if (isFullscreen) { fullApi.toggleFullscreen(); } if (!showPanel) { fullApi.togglePanel(); } const element = document.getElementById('storybook-panel-root'); if (element) { element.focus(); } break; } case 'nextStory': { fullApi.jumpToStory(1); break; } case 'prevStory': { fullApi.jumpToStory(-1); break; } case 'nextComponent': { fullApi.jumpToComponent(1); break; } case 'prevComponent': { fullApi.jumpToComponent(-1); break; } case 'fullScreen': { fullApi.toggleFullscreen(); break; } case 'togglePanel': { if (isFullscreen) { fullApi.toggleFullscreen(); } fullApi.togglePanel(); break; } case 'toggleNav': { if (isFullscreen) { fullApi.toggleFullscreen(); } fullApi.toggleNav(); break; } case 'toolbar': { fullApi.toggleToolbar(); break; } case 'panelPosition': { if (isFullscreen) { fullApi.toggleFullscreen(); } if (!showPanel) { fullApi.togglePanel(); } fullApi.togglePanelPosition(); break; } case 'aboutPage': { fullApi.navigate('/settings/about'); break; } case 'shortcutsPage': { fullApi.navigate('/settings/shortcuts'); break; } default: break; } }, }; const { shortcuts: persistedShortcuts = defaultShortcuts }: SubState = store.getState(); const state: SubState = { // Any saved shortcuts that are still in our set of defaults shortcuts: keys(defaultShortcuts).reduce( (acc, key) => ({ ...acc, [key]: persistedShortcuts[key] || defaultShortcuts[key] }), defaultShortcuts ), }; const init = ({ api: fullApi }: API) => { function focusInInput(event: Event) { return ( /input|textarea/i.test(event.target.tagName) || event.target.getAttribute('contenteditable') !== null ); } // Listen for keydown events in the manager document.addEventListener('keydown', (event: Event) => { if (!focusInInput(event)) { fullApi.handleKeydownEvent(fullApi, event); } }); // Also listen to keydown events sent over the channel fullApi.on(PREVIEW_KEYDOWN, (data: { event: Event }) => { fullApi.handleKeydownEvent(fullApi, data.event); }); }; const result = { api, state, init }; return result; }