REORGANISE tools & preview

This commit is contained in:
Norbert de Langen 2020-02-13 21:53:17 +01:00
parent 3503ccb113
commit 9d39929a6b
No known key found for this signature in database
GPG Key ID: 976651DA156C2825
14 changed files with 323 additions and 257 deletions

View File

@ -278,7 +278,8 @@ class ManagerProvider extends Component<Props, State> {
...state,
location: props.location,
path: props.path,
viewMode: props.viewMode,
// if its a docsOnly page, even the 'story' view mode is considered 'docs'
viewMode: (props.docsMode && props.viewMode) === 'story' ? 'docs' : props.viewMode,
storyId: props.storyId,
};
}

View File

@ -62,6 +62,7 @@ export class IFrame extends Component<IFrameProps & IframeHTMLAttributes<HTMLIFr
const { id, title, src, allowFullScreen, scale, ...rest } = this.props;
return (
<StyledIframe
data-is-storybook="true"
scrolling="yes"
id={id}
title={title}

View File

@ -1,6 +1,6 @@
import { types, Addon } from '@storybook/addons';
import { API, State } from '@storybook/api';
import { PreviewProps } from './PreviewProps';
import { PreviewProps } from './utils/types';
export const previewProps: PreviewProps = {
id: 'string',
@ -22,7 +22,23 @@ export const previewProps: PreviewProps = {
]
: []) as API['getElements'],
} as any) as API,
storyId: 'string',
story: {
id: 'storyId',
depth: 1,
isComponent: false,
isLeaf: true,
isRoot: false,
kind: 'kind',
name: 'story name',
parent: 'root',
children: [],
knownAs: 'storyId',
parameters: {
filename: '',
options: {},
docsOnly: false,
},
},
path: 'string',
viewMode: 'story',
location: ({} as any) as State['location'],
@ -35,5 +51,5 @@ export const previewProps: PreviewProps = {
withLoader: false,
docsOnly: false,
description: '',
parameters: {},
refs: {},
};

View File

@ -1,5 +1,5 @@
import React, { Fragment, FunctionComponent, useMemo, useEffect } from 'react';
import { API, Consumer, Combo, State } from '@storybook/api';
import { API, Consumer, Combo } from '@storybook/api';
import { SET_CURRENT_STORY } from '@storybook/core-events';
import addons, { types, Addon } from '@storybook/addons';
import merge from '@storybook/api/dist/lib/merge';
@ -7,17 +7,18 @@ import { Loader } from '@storybook/components';
import { Helmet } from 'react-helmet-async';
import { Toolbar, defaultTools, defaultToolsExtra, createTabsTool } from './toolbar';
import { Location } from '@storybook/router';
import * as S from './components';
import * as S from './utils/components';
import { ZoomProvider, ZoomConsumer } from './zoom';
import { ZoomProvider, ZoomConsumer } from './tools/zoom';
import { IFrame } from './iframe';
import { PreviewProps, ApplyWrappersProps, IframeRenderer } from './PreviewProps';
import { PreviewProps, ApplyWrappersProps, IframeRenderer } from './utils/types';
import { defaultWrappers, ApplyWrappers } from './wrappers';
import { stringifyQueryParams } from './stringifyQueryParams';
import { stringifyQueryParams } from './utils/stringifyQueryParams';
import { ToolbarComp } from './toolbar';
export const renderIframe: IframeRenderer = (
storyId,
@ -39,12 +40,11 @@ export const renderIframe: IframeRenderer = (
const getWrapper = (getFn: API['getElements']) => Object.values(getFn<Addon>(types.PREVIEW));
const getTabs = (getFn: API['getElements']) => Object.values(getFn<Addon>(types.TAB));
const getTools = (getFn: API['getElements']) => Object.values(getFn<Addon>(types.TOOL));
const getToolsExtra = (getFn: API['getElements']) => Object.values(getFn<Addon>(types.TOOLEXTRA));
const getDocumentTitle = (description: string) => {
return description ? `${description} ⋅ Storybook` : 'Storybook';
};
export const getTools = (getFn: API['getElements']) => Object.values(getFn<Addon>(types.TOOL));
export const getToolsExtra = (getFn: API['getElements']) =>
Object.values(getFn<Addon>(types.TOOLEXTRA));
const mapper = ({ state, api }: Combo) => ({
storyId: state.storyId,
@ -111,7 +111,7 @@ const useTabs = (
baseUrl: PreviewProps['baseUrl'],
withLoader: PreviewProps['withLoader'],
getElements: API['getElements'],
parameters: PreviewProps['parameters']
story: PreviewProps['story']
) => {
const canvas = useMemo(() => {
return createCanvas(id, baseUrl, withLoader);
@ -122,98 +122,58 @@ const useTabs = (
}, [getElements]);
return useMemo(() => {
return filterTabs([canvas, ...tabsFromConfig], parameters);
}, [canvas, ...tabsFromConfig, parameters]);
};
if (story && story.parameters) {
return filterTabs([canvas, ...tabsFromConfig], story.parameters);
}
const useViewMode = (docsOnly: boolean, viewMode: PreviewProps['viewMode']) => {
return docsOnly && viewMode === 'story' ? 'docs' : viewMode;
};
const useTools = (
getElements: API['getElements'],
tabs: Addon[],
viewMode: PreviewProps['viewMode'],
storyId: PreviewProps['storyId'],
location: PreviewProps['location'],
path: PreviewProps['path']
) => {
const toolsFromConfig = useMemo(() => {
return getTools(getElements);
}, [getElements]);
const toolsExtraFromConfig = useMemo(() => {
return getToolsExtra(getElements);
}, [getElements]);
const tools = useMemo(() => {
return [...defaultTools, ...toolsFromConfig];
}, [defaultTools, toolsFromConfig]);
const toolsExtra = useMemo(() => {
return [...defaultToolsExtra, ...toolsExtraFromConfig];
}, [defaultToolsExtra, toolsExtraFromConfig]);
return useMemo(() => {
return filterTools(tools, toolsExtra, tabs, {
viewMode,
storyId,
location,
path,
});
}, [viewMode, storyId, location, path, tools, toolsExtra, tabs]);
return [canvas, ...tabsFromConfig];
}, [story, canvas, ...tabsFromConfig]);
};
const Preview: FunctionComponent<PreviewProps> = props => {
const {
api,
id,
location,
options,
docsOnly = false,
storyId = undefined,
path = undefined,
description = undefined,
viewMode,
story = undefined,
description,
baseUrl = 'iframe.html',
parameters = undefined,
withLoader = true,
} = props;
const { isToolshown } = options;
const { getElements } = api;
// eslint-disable-next-line react/destructuring-assignment
const viewMode = useViewMode(docsOnly, props.viewMode);
const tabs = useTabs(id, baseUrl, withLoader, getElements, parameters);
const { left, right } = useTools(getElements, tabs, viewMode, storyId, location, path);
const tabs = useTabs(id, baseUrl, withLoader, getElements, story);
useEffect(() => {
api.emit(SET_CURRENT_STORY, { storyId, viewMode });
}, [storyId, viewMode]);
if (story) {
api.emit(SET_CURRENT_STORY, { storyId: story.id, viewMode });
}
}, [story, viewMode]);
return (
<ZoomProvider>
<Fragment>
{id === 'main' && (
<Helmet key="description">
<title>{getDocumentTitle(description)}</title>
</Helmet>
)}
{(left || right) && (
<Toolbar key="toolbar" shown={isToolshown} border>
<Fragment key="left">{left}</Fragment>
<Fragment key="right">{right}</Fragment>
</Toolbar>
)}
<Fragment>
{id === 'main' && (
<Helmet key="description">
<title>{description}</title>
</Helmet>
)}
<ZoomProvider>
<ToolbarComp story={story} api={api} isShown={isToolshown} tabs={tabs} />
<S.FrameWrap key="frame" offset={isToolshown ? 40 : 0}>
{tabs.map((p, i) => (
{tabs.map(({ render: Render, match, ...t }, i) => {
// @ts-ignore
<Fragment key={p.id || p.key || i}>
{p.render({ active: p.match({ storyId, viewMode, location, path }) })}
</Fragment>
))}
const key = t.id || t.key || i;
return (
<Fragment key={key}>
<Location>{lp => <Render active={match(lp)} />}</Location>
</Fragment>
);
})}
</S.FrameWrap>
</Fragment>
</ZoomProvider>
</ZoomProvider>
</Fragment>
);
};
@ -261,56 +221,3 @@ function filterTabs(panels: Addon[], parameters: Record<string, any>) {
}
return panels;
}
function filterTools(
tools: Addon[],
toolsExtra: Addon[],
tabs: Addon[],
{
viewMode,
storyId,
location,
path,
}: {
viewMode: State['viewMode'];
storyId: State['storyId'];
location: State['location'];
path: State['path'];
}
) {
const tabsTool = createTabsTool(tabs);
const toolsLeft = [tabs.filter(p => !p.hidden).length > 1 ? tabsTool : null, ...tools];
const toolsRight = [...toolsExtra];
// if its a docsOnly page, even the 'story' view mode is considered 'docs'
const filter = (item: Partial<Addon>) =>
item &&
(!item.match ||
item.match({
storyId,
viewMode,
location,
path,
}));
const displayItems = (list: Partial<Addon>[]) =>
list.reduce(
(acc, item, index) =>
item ? (
// @ts-ignore
<Fragment key={item.id || item.key || `f-${index}`}>
{acc}
{item.render({}) || item}
</Fragment>
) : (
acc
),
null
);
const left = displayItems(toolsLeft.filter(filter));
const right = displayItems(toolsRight.filter(filter));
return { left, right };
}

View File

@ -1,4 +0,0 @@
export const stringifyQueryParams = (queryParams: Record<string, string>) =>
Object.entries(queryParams).reduce((acc, [k, v]) => {
return `${acc}&${k}=${v}`;
}, '');

View File

@ -1,16 +1,20 @@
import React, { FunctionComponent, Fragment } from 'react';
import copy from 'copy-to-clipboard';
import React, { Fragment, useMemo, FunctionComponent, ReactElement } from 'react';
import { styled } from '@storybook/theming';
import { FlexBar, IconButton, Icons, Separator, TabButton, TabBar } from '@storybook/components';
import { Consumer, Combo } from '@storybook/api';
import { Consumer, Combo, API, Story, Group, State } from '@storybook/api';
import { Addon } from '@storybook/addons';
import { stringifyQueryParams } from './stringifyQueryParams';
import { ZoomConsumer, Zoom } from './zoom';
import { Location, RenderData } from '@storybook/router';
import { ZoomConsumer, Zoom, zoomTool } from './tools/zoom';
import * as S from './components';
import * as S from './utils/components';
import { PreviewProps } from './utils/types';
import { getTools, getToolsExtra } from './preview';
import { copyTool } from './tools/copy';
import { ejectTool } from './tools/eject';
const Bar: FunctionComponent<{ shown: boolean } & Record<string, any>> = ({ shown, ...props }) => (
<FlexBar {...props} />
@ -54,74 +58,6 @@ export const fullScreenTool: Addon = {
),
};
const copyMapper = ({ state }: Combo) => ({
origin: state.location.origin,
pathname: state.location.pathname,
storyId: state.storyId,
baseUrl: 'iframe.html',
queryParams: state.customQueryParams,
});
export const copyTool: Addon = {
title: 'copy',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={copyMapper}>
{({ baseUrl, storyId, origin, pathname, queryParams }: ReturnType<typeof copyMapper>) => (
<IconButton
key="copy"
onClick={() =>
copy(`${origin}${pathname}${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`)
}
title="Copy canvas link"
>
<Icons icon="copy" />
</IconButton>
)}
</Consumer>
),
};
const ejectMapper = ({ state }: Combo) => ({
baseUrl: 'iframe.html',
storyId: state.storyId,
queryParams: state.customQueryParams,
});
export const ejectTool: Addon = {
title: 'eject',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={ejectMapper}>
{({ baseUrl, storyId, queryParams }: ReturnType<typeof ejectMapper>) => (
<IconButton
key="opener"
href={`${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`}
target="_blank"
title="Open canvas in new tab"
>
<Icons icon="share" />
</IconButton>
)}
</Consumer>
),
};
const zoomTool: Addon = {
title: 'zoom',
match: p => p.viewMode === 'story',
render: () => (
<Fragment>
<ZoomConsumer>
{({ set, value }) => (
<Zoom key="zoom" set={(v: number) => set(value * v)} reset={() => set(1)} />
)}
</ZoomConsumer>
<Separator />
</Fragment>
),
};
const tabsMapper = ({ state }: Combo) => ({
viewMode: state.docsOnly,
storyId: state.storyId,
@ -159,3 +95,119 @@ export const createTabsTool = (tabs: Addon[]): Addon => ({
export const defaultTools: Addon[] = [zoomTool];
export const defaultToolsExtra: Addon[] = [fullScreenTool, ejectTool, copyTool];
const useTools = (
getElements: API['getElements'],
tabs: Addon[],
viewMode: PreviewProps['viewMode'],
story: PreviewProps['story'],
location: PreviewProps['location'],
path: PreviewProps['path']
) => {
const toolsFromConfig = useMemo(() => {
return getTools(getElements);
}, [getElements]);
const toolsExtraFromConfig = useMemo(() => {
return getToolsExtra(getElements);
}, [getElements]);
const tools = useMemo(() => {
return [...defaultTools, ...toolsFromConfig];
}, [defaultTools, toolsFromConfig]);
const toolsExtra = useMemo(() => {
return [...defaultToolsExtra, ...toolsExtraFromConfig];
}, [defaultToolsExtra, toolsExtraFromConfig]);
return useMemo(() => {
if (story && story.parameters) {
return filterTools(tools, toolsExtra, tabs, {
viewMode,
story,
location,
path,
});
}
return { left: tools, right: toolsExtra };
}, [viewMode, story, location, path, tools, toolsExtra, tabs]);
};
export interface ToolData {
isShown: boolean;
tabs: Addon[];
api: API;
story: Story | Group;
}
export const ToolRes: FunctionComponent<ToolData & RenderData> = ({
api,
story,
tabs,
isShown,
location,
path,
viewMode,
}) => {
const { left, right } = useTools(api.getElements, tabs, viewMode, story, location, path);
return left || right ? (
<Toolbar key="toolbar" shown={isShown} border>
<Tools key="left" list={left} />
<Tools key="right" list={right} />
</Toolbar>
) : null;
};
export const ToolbarComp: FunctionComponent<ToolData> = p => (
<Location>{l => <ToolRes {...l} {...p} />}</Location>
);
export const Tools: FunctionComponent<{
list: Addon[];
}> = ({ list }) =>
list.filter(Boolean).reduce((acc, { render: Render, id, ...t }, index) => {
// @ts-ignore
const key = id || t.key || `f-${index}`;
return (
<Fragment key={key}>
{acc}
<Render />
</Fragment>
);
}, null as ReactElement);
export function filterTools(
tools: Addon[],
toolsExtra: Addon[],
tabs: Addon[],
{
viewMode,
story,
location,
path,
}: {
viewMode: State['viewMode'];
story: PreviewProps['story'];
location: State['location'];
path: State['path'];
}
) {
const tabsTool = createTabsTool(tabs);
const toolsLeft = [tabs.filter(p => !p.hidden).length > 1 ? tabsTool : null, ...tools];
const toolsRight = [...toolsExtra];
const filter = (item: Partial<Addon>) =>
item &&
(!item.match ||
item.match({
storyId: story.id,
viewMode,
location,
path,
}));
const left = toolsLeft.filter(filter);
const right = toolsRight.filter(filter);
return { left, right };
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import copy from 'copy-to-clipboard';
import { IconButton, Icons } from '@storybook/components';
import { Consumer, Combo } from '@storybook/api';
import { Addon } from '@storybook/addons';
import { stringifyQueryParams } from '../utils/stringifyQueryParams';
const copyMapper = ({ state }: Combo) => ({
origin: state.location.origin,
pathname: state.location.pathname,
storyId: state.storyId,
baseUrl: 'iframe.html',
queryParams: state.customQueryParams,
});
export const copyTool: Addon = {
title: 'copy',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={copyMapper}>
{({ baseUrl, storyId, origin, pathname, queryParams }: ReturnType<typeof copyMapper>) => (
<IconButton
key="copy"
onClick={() =>
copy(`${origin}${pathname}${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`)
}
title="Copy canvas link"
>
<Icons icon="copy" />
</IconButton>
)}
</Consumer>
),
};

View File

@ -0,0 +1,30 @@
import React from 'react';
import { IconButton, Icons } from '@storybook/components';
import { Consumer, Combo } from '@storybook/api';
import { Addon } from '@storybook/addons';
import { stringifyQueryParams } from '../utils/stringifyQueryParams';
const ejectMapper = ({ state }: Combo) => ({
baseUrl: 'iframe.html',
storyId: state.storyId,
queryParams: state.customQueryParams,
});
export const ejectTool: Addon = {
title: 'eject',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={ejectMapper}>
{({ baseUrl, storyId, queryParams }: ReturnType<typeof ejectMapper>) => (
<IconButton
key="opener"
href={`${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`}
target="_blank"
title="Open canvas in new tab"
>
<Icons icon="share" />
</IconButton>
)}
</Consumer>
),
};

View File

@ -1,10 +1,11 @@
import React, { Fragment, Component, FunctionComponent, SyntheticEvent } from 'react';
import { Icons, IconButton } from '@storybook/components';
import { Icons, IconButton, Separator } from '@storybook/components';
import { Addon } from '@storybook/addons';
const Context = React.createContext({ value: 1, set: (v: number) => {} });
class Provider extends Component<{}, { value: number }> {
class ZoomProvider extends Component<{}, { value: number }> {
state = {
value: 1,
};
@ -20,7 +21,7 @@ class Provider extends Component<{}, { value: number }> {
}
}
const { Consumer } = Context;
const { Consumer: ZoomConsumer } = Context;
const cancel = (e: SyntheticEvent) => {
e.preventDefault();
@ -49,4 +50,19 @@ const Zoom: FunctionComponent<{ set: Function; reset: Function }> = ({ set, rese
</Fragment>
);
export { Zoom, Consumer as ZoomConsumer, Provider as ZoomProvider };
export { Zoom, ZoomConsumer, ZoomProvider };
export const zoomTool: Addon = {
title: 'zoom',
match: p => p.viewMode === 'story',
render: () => (
<Fragment>
<ZoomConsumer>
{({ set, value }) => (
<Zoom key="zoom" set={(v: number) => set(value * v)} reset={() => set(1)} />
)}
</ZoomConsumer>
<Separator />
</Fragment>
),
};

View File

@ -26,3 +26,14 @@ export const DesktopOnly = styled.span({
display: 'none',
},
});
export const IframeWrapper = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
width: '100%',
height: '100%',
background: theme.background.content,
}));

View File

@ -0,0 +1,2 @@
export const stringifyQueryParams = (queryParams: Record<string, string>) =>
Object.entries(queryParams).reduce((acc, [k, v]) => `${acc}&${k}=${v}`, '');

View File

@ -1,12 +1,13 @@
import { State, API } from '@storybook/api';
import { State, API, Story, Group } from '@storybook/api';
import { FunctionComponent, ReactNode } from 'react';
type ViewMode = State['viewMode'];
export interface PreviewProps {
api: API;
storyId: string;
viewMode: ViewMode;
refs: State['refs'];
story: Group | Story;
docsOnly: boolean;
options: {
isFullscreen: boolean;
@ -19,7 +20,6 @@ export interface PreviewProps {
customCanvas?: IframeRenderer;
description: string;
baseUrl: string;
parameters: Record<string, any>;
withLoader: boolean;
}

View File

@ -1,6 +1,6 @@
import React, { Fragment, FunctionComponent } from 'react';
import { styled } from '@storybook/theming';
import { ApplyWrappersProps, Wrapper } from './PreviewProps';
import { ApplyWrappersProps, Wrapper } from './utils/types';
import { IframeWrapper } from './utils/components';
export const ApplyWrappers: FunctionComponent<ApplyWrappersProps> = ({
wrappers,
@ -19,16 +19,6 @@ export const ApplyWrappers: FunctionComponent<ApplyWrappersProps> = ({
);
};
const IframeWrapper = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
width: '100%',
height: '100%',
background: theme.background.content,
}));
export const defaultWrappers = [
{
render: p => (

View File

@ -1,10 +1,12 @@
import { PREVIEW_URL } from 'global';
import React from 'react';
import { State, Consumer, Combo, StoriesHash } from '@storybook/api';
import { Consumer, Combo, StoriesHash, isRoot, isGroup, isStory } from '@storybook/api';
import { Preview } from '../components/preview/preview';
import { PreviewProps } from '../components/preview/PreviewProps';
import { PreviewProps } from '../components/preview/utils/types';
type Item = StoriesHash[keyof StoriesHash];
const nonAlphanumSpace = /[^a-z0-9 ]/gi;
const doubleSpace = /\s\s/gi;
@ -13,51 +15,59 @@ const replacer = (match: string) => ` ${match} `;
const addExtraWhiteSpace = (input: string) =>
input.replace(nonAlphanumSpace, replacer).replace(doubleSpace, ' ');
const getDescription = (storiesHash: StoriesHash, storyId: string) => {
const storyInfo = storiesHash[storyId];
if (storyInfo) {
// @ts-ignore
const { kind, name } = storyInfo;
return kind && name ? addExtraWhiteSpace(`${kind} - ${name}`) : '';
const getDescription = (item: Item) => {
if (isRoot(item)) {
return item.name ? `${item.name} ⋅ Storybook` : 'Storybook';
}
if (isGroup(item)) {
return item.name ? `${item.name} ⋅ Storybook` : 'Storybook';
}
if (isStory(item)) {
const { kind, name } = item;
return kind && name ? addExtraWhiteSpace(`${kind} - ${name} ⋅ Storybook`) : 'Storybook';
}
return '';
return 'Storybook';
};
const mapper = ({ api, state }: Combo) => {
const { layout, location, customQueryParams, storiesHash, storyId } = state;
const { parameters } = storiesHash[storyId] || {};
const { layout, location, customQueryParams, storyId, refs, viewMode, path } = state;
const story = api.getData(storyId);
const docsOnly = story && story.parameters ? !!story.parameters.docsOnly : false;
return {
api,
story,
options: layout,
description: getDescription(storiesHash, storyId),
...api.getUrlState(),
description: getDescription(story),
viewMode,
path,
refs,
queryParams: customQueryParams,
docsOnly: (parameters && parameters.docsOnly) as boolean,
docsOnly,
location,
parameters,
};
};
function getBaseUrl(): string {
const getBaseUrl = (): string => {
try {
return PREVIEW_URL || 'iframe.html';
} catch (e) {
return 'iframe.html';
}
}
};
const PreviewConnected = React.memo<{ id: string; withLoader: boolean }>(props => (
<Consumer filter={mapper}>
{(fromState: ReturnType<typeof mapper>) => {
const p = {
const p: PreviewProps = {
...props,
baseUrl: getBaseUrl(),
...fromState,
} as PreviewProps;
};
return <Preview {...p} />;
return <pre>{JSON.stringify(p, null, 2)}</pre>;
}}
</Consumer>
));