mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-06 07:21:16 +08:00
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
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: 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<Shortcuts>;
|
|
setShortcut(action: Action, value: KeyCollection): Promise<KeyCollection>;
|
|
setAddonShortcut(addon: string, shortcut: AddonShortcut): Promise<AddonShortcut>;
|
|
restoreAllDefaultShortcuts(): Promise<Shortcuts>;
|
|
restoreDefaultShortcut(action: Action): Promise<KeyCollection>;
|
|
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<string, AddonShortcut>;
|
|
type AddonShortcutLabels = Record<string, string>;
|
|
type AddonShortcutDefaults = Record<string, KeyCollection>;
|
|
|
|
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 };
|
|
};
|