import { navigator, document } from 'global'; import { PREVIEW_KEYDOWN } from '@storybook/core-events'; import { ModuleFn } from '../index'; import { shortcutMatchesShortcut, eventToShortcut } from '../lib/shortcut'; import { focusableUIElements } from './layout'; 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 (keyof O)[]; } export interface SubState { shortcuts: Shortcuts; } export interface SubAPI { getShortcutKeys(): Shortcuts; getDefaultShortcuts(): Shortcuts | AddonShortcutDefaults; getAddonsShortcuts(): AddonShortcuts; getAddonsShortcutLabels(): AddonShortcutLabels; getAddonsShortcutDefaults(): AddonShortcutDefaults; setShortcuts(shortcuts: Shortcuts): Promise; setShortcut(action: Action, value: KeyCollection): Promise; setAddonShortcut(addon: string, shortcut: AddonShortcut): Promise; restoreAllDefaultShortcuts(): Promise; restoreDefaultShortcut(action: Action): Promise; handleKeydownEvent(event: Event): void; handleShortcutFeature(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; collapseAll: KeyCollection; expandAll: KeyCollection; } export type Action = keyof Shortcuts; interface AddonShortcut { label: string; defaultShortcut: KeyCollection; actionName: string; showInMenu?: boolean; action: (...args: any[]) => any; } type AddonShortcuts = Record; type AddonShortcutLabels = Record; type AddonShortcutDefaults = Record; export const defaultShortcuts: Shortcuts = Object.freeze({ fullScreen: ['F'], togglePanel: ['A'], panelPosition: ['D'], toggleNav: ['S'], 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 collapseAll: [controlOrMetaKey(), 'shift', 'ArrowUp'], expandAll: [controlOrMetaKey(), 'shift', 'ArrowDown'], }); const addonsShortcuts: AddonShortcuts = {}; export interface Event extends KeyboardEvent { target: { tagName: string; addEventListener(): void; removeEventListener(): boolean; dispatchEvent(event: Event): boolean; getAttribute(attr: string): string | null; }; } function focusInInput(event: Event) { return ( /input|textarea/i.test(event.target.tagName) || event.target.getAttribute('contenteditable') !== null ); } export const init: ModuleFn = ({ store, fullAPI }) => { const api: SubAPI = { // Getting and setting shortcuts getShortcutKeys(): Shortcuts { return store.getState().shortcuts; }, getDefaultShortcuts(): Shortcuts | AddonShortcutDefaults { return { ...defaultShortcuts, ...api.getAddonsShortcutDefaults(), }; }, getAddonsShortcuts(): AddonShortcuts { return addonsShortcuts; }, getAddonsShortcutLabels(): AddonShortcutLabels { const labels: AddonShortcutLabels = {}; Object.entries(api.getAddonsShortcuts()).forEach(([actionName, { label }]) => { labels[actionName] = label; }); return labels; }, getAddonsShortcutDefaults(): AddonShortcutDefaults { const defaults: AddonShortcutDefaults = {}; Object.entries(api.getAddonsShortcuts()).forEach(([actionName, { defaultShortcut }]) => { defaults[actionName] = defaultShortcut; }); return defaults; }, async setShortcuts(shortcuts: Shortcuts) { await store.setState({ shortcuts }, { persistence: 'permanent' }); return shortcuts; }, async restoreAllDefaultShortcuts() { return api.setShortcuts(api.getDefaultShortcuts() as Shortcuts); }, async setShortcut(action, value) { const shortcuts = api.getShortcutKeys(); await api.setShortcuts({ ...shortcuts, [action]: value }); return value; }, async setAddonShortcut(addon: string, shortcut: AddonShortcut) { const shortcuts = api.getShortcutKeys(); await api.setShortcuts({ ...shortcuts, [`${addon}-${shortcut.actionName}`]: shortcut.defaultShortcut, }); addonsShortcuts[`${addon}-${shortcut.actionName}`] = shortcut; return shortcut; }, async restoreDefaultShortcut(action) { const defaultShortcut = api.getDefaultShortcuts()[action]; return api.setShortcut(action, defaultShortcut); }, // Listening to shortcut events handleKeydownEvent(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) { // Event.prototype.preventDefault is missing when received from the MessageChannel. if (event?.preventDefault) event.preventDefault(); api.handleShortcutFeature(matchedFeature); } }, // warning: event might not have a full prototype chain because it may originate from the channel handleShortcutFeature(feature) { const { layout: { isFullscreen, showNav, showPanel }, ui: { enableShortcuts }, } = store.getState(); if (!enableShortcuts) { return; } switch (feature) { case 'escape': { if (isFullscreen) { fullAPI.toggleFullscreen(); } else if (!showNav) { fullAPI.toggleNav(); } break; } case 'focusNav': { if (isFullscreen) { fullAPI.toggleFullscreen(); } if (!showNav) { fullAPI.toggleNav(); } fullAPI.focusOnUIElement(focusableUIElements.storyListMenu); break; } case 'search': { if (isFullscreen) { fullAPI.toggleFullscreen(); } if (!showNav) { fullAPI.toggleNav(); } setTimeout(() => { fullAPI.focusOnUIElement(focusableUIElements.storySearchField, true); }, 0); 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(); } fullAPI.focusOnUIElement(focusableUIElements.storyPanelRoot); 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.resetLayout(); } fullAPI.togglePanel(); break; } case 'toggleNav': { if (isFullscreen) { fullAPI.toggleFullscreen(); fullAPI.resetLayout(); } 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; } case 'collapseAll': { fullAPI.collapseAll(); break; } case 'expandAll': { fullAPI.expandAll(); break; } default: addonsShortcuts[feature].action(); 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 initModule = () => { // Listen for keydown events in the manager document.addEventListener('keydown', (event: Event) => { if (!focusInInput(event)) { fullAPI.handleKeydownEvent(event); } }); // Also listen to keydown events sent over the channel fullAPI.on(PREVIEW_KEYDOWN, (data: { event: Event }) => { fullAPI.handleKeydownEvent(data.event); }); }; return { api, state, init: initModule }; };