From 95919b86183e1afafcf31fdc46618bd4f335afac Mon Sep 17 00:00:00 2001 From: Tom Coleman Date: Wed, 29 Jun 2022 12:05:38 +1000 Subject: [PATCH] WIP --- addons/docs/src/blocks/Meta.tsx | 2 +- lib/preview-web/src/DocsRender.ts | 200 ------------------ lib/preview-web/src/Preview.tsx | 6 +- lib/preview-web/src/PreviewWeb.tsx | 63 +++--- .../src/render/AbstractDocsRender.ts | 63 ++++++ lib/preview-web/src/render/Render.ts | 14 ++ .../src/render/StandaloneDocsRender.ts | 123 +++++++++++ .../src/{ => render}/StoryRender.ts | 21 +- .../src/render/TemplateDocsRender.ts | 103 +++++++++ 9 files changed, 345 insertions(+), 250 deletions(-) delete mode 100644 lib/preview-web/src/DocsRender.ts create mode 100644 lib/preview-web/src/render/AbstractDocsRender.ts create mode 100644 lib/preview-web/src/render/Render.ts create mode 100644 lib/preview-web/src/render/StandaloneDocsRender.ts rename lib/preview-web/src/{ => render}/StoryRender.ts (93%) create mode 100644 lib/preview-web/src/render/TemplateDocsRender.ts diff --git a/addons/docs/src/blocks/Meta.tsx b/addons/docs/src/blocks/Meta.tsx index 12f8f285fd80..def67c709cc2 100644 --- a/addons/docs/src/blocks/Meta.tsx +++ b/addons/docs/src/blocks/Meta.tsx @@ -18,7 +18,7 @@ function getFirstStoryId(docsContext: DocsContextProps): string { function renderAnchor() { const context = useContext(DocsContext); - if (context.type !== 'legacy') { + if (context.type === 'external') { return null; } const anchorId = getFirstStoryId(context) || context.id; diff --git a/lib/preview-web/src/DocsRender.ts b/lib/preview-web/src/DocsRender.ts deleted file mode 100644 index 1c0e98f6edfa..000000000000 --- a/lib/preview-web/src/DocsRender.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { AnyFramework, StoryId, ViewMode, StoryContextForLoaders } from '@storybook/csf'; -import { - Story, - StoryStore, - CSFFile, - ModuleExports, - IndexEntry, - ModuleExport, -} from '@storybook/store'; -import { Channel } from '@storybook/addons'; -import { DOCS_RENDERED } from '@storybook/core-events'; - -import { Render, RenderType } from './StoryRender'; -import type { DocsContextProps, DocsRenderFunction } from './types'; - -export class DocsRender implements Render { - public type: RenderType = 'docs'; - - public id: StoryId; - - public legacy: boolean; - - public story?: Story; - - public exports?: ModuleExports; - - private csfFiles?: CSFFile[]; - - private preparing = false; - - private canvasElement?: HTMLElement; - - private docsContext?: DocsContextProps; - - public disableKeyListeners = false; - - public teardown?: (options: { viewModeChanged?: boolean }) => Promise; - - public torndown = false; - - constructor( - private channel: Channel, - private store: StoryStore, - public entry: IndexEntry - ) { - this.id = entry.id; - this.legacy = entry.type !== 'docs' || !entry.standalone; - } - - // The two story "renders" are equal and have both loaded the same story - isEqual(other?: Render) { - if (!other) return false; - return this.id === other.id && this.legacy - ? this.story && this.story === other.story - : other.type === 'docs' && this.entry === (other as DocsRender).entry; - } - - async prepare() { - this.preparing = true; - if (this.legacy) { - this.story = await this.store.loadStory({ storyId: this.id }); - } else { - const { docsExports, csfFiles } = await this.store.loadDocsFileById(this.id); - this.exports = docsExports; - this.csfFiles = csfFiles; - } - this.preparing = false; - } - - isPreparing() { - return this.preparing; - } - - async getDocsContext( - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ): Promise> { - const { id, title, name } = this.entry; - - const base = { - type: this.legacy ? 'legacy' : ('modern' as DocsContextProps['type']), - id, - title, - name, - loadStory: (storyId: StoryId) => this.store.loadStory({ storyId }), - renderStoryToElement, - getStoryContext: (renderedStory: Story) => - ({ - ...this.store.getStoryContext(renderedStory), - viewMode: 'docs' as ViewMode, - } as StoryContextForLoaders), - }; - - if (this.legacy) { - const csfFile: CSFFile = await this.store.loadCSFFileByStoryId(this.id); - const componentStories = () => this.store.componentStoriesFromCSFFile({ csfFile }); - return { - ...base, - - storyIdByModuleExport: () => { - // NOTE: we could implement this easily enough by checking all the component stories - throw new Error('`storyIdByModuleExport` not available for legacy docs files.'); - }, - storyById: (storyId: StoryId) => this.store.storyFromCSFFile({ storyId, csfFile }), - - componentStories, - setMeta: () => {}, - }; - } - - if (!this.csfFiles) throw new Error('getDocsContext called before prepare'); - - let metaCsfFile: ModuleExports; - const exportToStoryId = new Map(); - const storyIdToCSFFile = new Map>(); - // eslint-disable-next-line no-restricted-syntax - for (const csfFile of this.csfFiles) { - // eslint-disable-next-line no-restricted-syntax - for (const annotation of Object.values(csfFile.stories)) { - exportToStoryId.set(annotation.moduleExport, annotation.id); - storyIdToCSFFile.set(annotation.id, csfFile); - } - } - const storyById = (storyId: StoryId) => { - const csfFile = storyIdToCSFFile.get(storyId); - if (!csfFile) - throw new Error(`Called \`storyById\` for story that was never loaded: ${storyId}`); - return this.store.storyFromCSFFile({ storyId, csfFile }); - }; - - return { - ...base, - storyIdByModuleExport: (moduleExport) => { - const storyId = exportToStoryId.get(moduleExport); - if (storyId) return storyId; - - throw new Error(`No story found with that export: ${moduleExport}`); - }, - storyById, - componentStories: () => { - return ( - Object.entries(metaCsfFile) - .map(([_, moduleExport]) => exportToStoryId.get(moduleExport)) - .filter(Boolean) as StoryId[] - ).map(storyById); - }, - setMeta(m: ModuleExports) { - metaCsfFile = m; - }, - }; - } - - async renderToElement( - canvasElement: HTMLElement, - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ) { - this.canvasElement = canvasElement; - this.docsContext = await this.getDocsContext(renderStoryToElement); - - return this.render(); - } - - async render() { - if ( - !(this.story || this.exports) || - !this.docsContext || - !this.canvasElement || - !this.store.projectAnnotations - ) - throw new Error('DocsRender not ready to render'); - - const { docs } = this.story?.parameters || this.store.projectAnnotations.parameters || {}; - - if (!docs) { - throw new Error( - `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` - ); - } - - const renderer = await docs.renderer(); - (renderer.render as DocsRenderFunction)( - this.docsContext, - { - ...docs, - // exports must be defined in non-legacy mode (see check at top) - ...(!this.legacy && { page: this.exports!.default }), - }, - this.canvasElement, - () => this.channel.emit(DOCS_RENDERED, this.id) - ); - this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { - if (!viewModeChanged || !this.canvasElement) return; - renderer.unmount(this.canvasElement); - this.torndown = true; - }; - } - - async rerender() { - // We don't need to do anything here, as the stories will individually re-render - } -} diff --git a/lib/preview-web/src/Preview.tsx b/lib/preview-web/src/Preview.tsx index a179c0229399..bbeef91aa25a 100644 --- a/lib/preview-web/src/Preview.tsx +++ b/lib/preview-web/src/Preview.tsx @@ -26,8 +26,8 @@ import { RenderToDOM, } from '@storybook/store'; -import { StoryRender } from './StoryRender'; -import { DocsRender } from './DocsRender'; +import { StoryRender } from './render/StoryRender'; +import { AbstractDocsRender } from './render/AbstractDocsRender'; const { fetch } = global; @@ -324,7 +324,7 @@ export class Preview { } async teardownRender( - render: StoryRender | DocsRender, + render: StoryRender | AbstractDocsRender, { viewModeChanged }: { viewModeChanged?: boolean } = {} ) { this.storyRenders = this.storyRenders.filter((r) => r !== render); diff --git a/lib/preview-web/src/PreviewWeb.tsx b/lib/preview-web/src/PreviewWeb.tsx index d52e65fbb74d..c0fa7320317d 100644 --- a/lib/preview-web/src/PreviewWeb.tsx +++ b/lib/preview-web/src/PreviewWeb.tsx @@ -35,8 +35,9 @@ import { Preview } from './Preview'; import { UrlStore } from './UrlStore'; import { WebView } from './WebView'; -import { PREPARE_ABORTED, Render, StoryRender } from './StoryRender'; -import { DocsRender } from './DocsRender'; +import { PREPARE_ABORTED, StoryRender } from './render/StoryRender'; +import { TemplateDocsRender } from './render/TemplateDocsRender'; +import { StandaloneDocsRender } from './render/StandaloneDocsRender'; const { window: globalWindow } = global; @@ -46,6 +47,16 @@ function focusInInput(event: Event) { } type MaybePromise = Promise | T; +type PossibleRender = + | StoryRender + | TemplateDocsRender + | StandaloneDocsRender; + +function isStoryRender( + r: PossibleRender +): r is StoryRender { + return r.type === 'story'; +} export class PreviewWeb extends Preview { urlStore: UrlStore; @@ -56,7 +67,7 @@ export class PreviewWeb extends Preview | DocsRender; + currentRender?: PossibleRender; constructor() { super(); @@ -217,21 +228,10 @@ export class PreviewWeb extends Preview1 story on the page and there is no easy way to keep track - // of which ones were rendered by the docs page. - // However, in `modernInlineRender`, the individual stories track their own events as they - // each call `renderStoryToElement` below. - if (this.currentRender instanceof DocsRender) { - await this.currentRender.rerender(); - } } async onPreloadStories(ids: string[]) { @@ -261,15 +261,12 @@ export class PreviewWeb extends Preview extends Preview; + if (entry.type === 'story') { render = new StoryRender( this.channel, this.storyStore, @@ -299,8 +296,10 @@ export class PreviewWeb extends Preview(this.channel, this.storyStore, entry); } else { - render = new DocsRender(this.channel, this.storyStore, entry); + render = new TemplateDocsRender(this.channel, this.storyStore, entry); } // We need to store this right away, so if the story changes during @@ -324,7 +323,7 @@ export class PreviewWeb extends Preview extends Preview extends Preview); (this.currentRender as StoryRender).renderToElement( this.view.prepareForStory(render.story) ); + } else { + this.currentRender.renderToElement( + this.view.prepareForDocs(), + this.renderStoryToElement.bind(this) + ); } } @@ -418,7 +417,7 @@ export class PreviewWeb extends Preview, + render: PossibleRender, { viewModeChanged = false }: { viewModeChanged?: boolean } = {} ) { this.storyRenders = this.storyRenders.filter((r) => r !== render); diff --git a/lib/preview-web/src/render/AbstractDocsRender.ts b/lib/preview-web/src/render/AbstractDocsRender.ts new file mode 100644 index 000000000000..cc176d65a08a --- /dev/null +++ b/lib/preview-web/src/render/AbstractDocsRender.ts @@ -0,0 +1,63 @@ +import { AnyFramework, StoryId, ViewMode, StoryContextForLoaders } from '@storybook/csf'; +import { Story, StoryStore, IndexEntry } from '@storybook/store'; +import { Channel } from '@storybook/addons'; +import { Render, RenderType } from './Render'; +import { DocsContextProps } from '../types'; + +export abstract class AbstractDocsRender + implements Render +{ + public type: RenderType = 'docs'; + + public id: StoryId; + + protected preparing = false; + + protected canvasElement?: HTMLElement; + + protected docsContext?: DocsContextProps; + + public disableKeyListeners = false; + + public teardown?: (options: { viewModeChanged?: boolean }) => Promise; + + public torndown = false; + + constructor( + protected channel: Channel, + protected store: StoryStore, + public entry: IndexEntry + ) { + this.id = entry.id; + } + + isPreparing() { + return this.preparing; + } + + loadStory = (storyId: StoryId) => this.store.loadStory({ storyId }); + + getStoryContext = (renderedStory: Story) => + ({ + ...this.store.getStoryContext(renderedStory), + viewMode: 'docs' as ViewMode, + } as StoryContextForLoaders); + + async renderToElement( + canvasElement: HTMLElement, + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ) { + this.canvasElement = canvasElement; + this.docsContext = await this.getDocsContext(renderStoryToElement); + + return this.render(); + } + + abstract isEqual(r: Render): boolean; + + abstract render(): Promise; + + abstract getDocsContext( + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ): Promise>; +} diff --git a/lib/preview-web/src/render/Render.ts b/lib/preview-web/src/render/Render.ts new file mode 100644 index 000000000000..cb6f00d1fc40 --- /dev/null +++ b/lib/preview-web/src/render/Render.ts @@ -0,0 +1,14 @@ +import { StoryId, AnyFramework } from '@storybook/csf'; +import { Story } from '@storybook/store'; + +export type RenderType = 'story' | 'docs'; +export interface Render { + type: RenderType; + id: StoryId; + isPreparing: () => boolean; + isEqual: (other: Render) => boolean; + disableKeyListeners: boolean; + teardown?: (options: { viewModeChanged: boolean }) => Promise; + torndown: boolean; + renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise; +} diff --git a/lib/preview-web/src/render/StandaloneDocsRender.ts b/lib/preview-web/src/render/StandaloneDocsRender.ts new file mode 100644 index 000000000000..eeceec04e4a1 --- /dev/null +++ b/lib/preview-web/src/render/StandaloneDocsRender.ts @@ -0,0 +1,123 @@ +import { AnyFramework, StoryId } from '@storybook/csf'; +import { CSFFile, ModuleExports, ModuleExport } from '@storybook/store'; +import { DOCS_RENDERED } from '@storybook/core-events'; + +import { Render, RenderType } from './Render'; +import type { DocsContextProps, DocsRenderFunction } from '../types'; +import { AbstractDocsRender } from './AbstractDocsRender'; + +export class StandaloneDocsRender< + TFramework extends AnyFramework +> extends AbstractDocsRender { + public type: RenderType = 'docs'; + + public exports?: ModuleExports; + + private csfFiles?: CSFFile[]; + + async prepare() { + this.preparing = true; + const { docsExports, csfFiles } = await this.store.loadDocsFileById(this.id); + this.exports = docsExports; + this.csfFiles = csfFiles; + this.preparing = false; + } + + isEqual(other: Render): boolean { + return !!( + this.id === other.id && + this.entry && + this.entry === (other as StandaloneDocsRender).entry + ); + } + + async getDocsContext( + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ): Promise> { + const { id, title, name } = this.entry; + + if (!this.csfFiles) throw new Error('getDocsContext called before prepare'); + + let metaCsfFile: ModuleExports; + const setMeta = (m: ModuleExports) => { + metaCsfFile = m; + }; + + const exportToStoryId = new Map(); + const storyIdToCSFFile = new Map>(); + // eslint-disable-next-line no-restricted-syntax + for (const csfFile of this.csfFiles) { + // eslint-disable-next-line no-restricted-syntax + for (const annotation of Object.values(csfFile.stories)) { + exportToStoryId.set(annotation.moduleExport, annotation.id); + storyIdToCSFFile.set(annotation.id, csfFile); + } + } + + const storyIdByModuleExport = (moduleExport: ModuleExport) => { + const storyId = exportToStoryId.get(moduleExport); + if (storyId) return storyId; + + throw new Error(`No story found with that export: ${moduleExport}`); + }; + + const storyById = (storyId: StoryId) => { + const csfFile = storyIdToCSFFile.get(storyId); + if (!csfFile) + throw new Error(`Called \`storyById\` for story that was never loaded: ${storyId}`); + return this.store.storyFromCSFFile({ storyId, csfFile }); + }; + + const componentStories = () => { + return ( + Object.entries(metaCsfFile) + .map(([_, moduleExport]) => exportToStoryId.get(moduleExport)) + .filter(Boolean) as StoryId[] + ).map(storyById); + }; + + return { + // TODO + type: 'modern', + id, + title, + name, + renderStoryToElement, + loadStory: this.loadStory, + getStoryContext: this.getStoryContext, + storyIdByModuleExport, + storyById, + componentStories, + setMeta, + }; + } + + async render() { + if (!this.exports || !this.docsContext || !this.canvasElement || !this.store.projectAnnotations) + throw new Error('DocsRender not ready to render'); + + const { docs } = this.store.projectAnnotations.parameters || {}; + + if (!docs) { + throw new Error( + `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` + ); + } + + const renderer = await docs.renderer(); + (renderer.render as DocsRenderFunction)( + this.docsContext, + { + ...docs, + page: this.exports.default, + }, + this.canvasElement, + () => this.channel.emit(DOCS_RENDERED, this.id) + ); + this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { + if (!viewModeChanged || !this.canvasElement) return; + renderer.unmount(this.canvasElement); + this.torndown = true; + }; + } +} diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/render/StoryRender.ts similarity index 93% rename from lib/preview-web/src/StoryRender.ts rename to lib/preview-web/src/render/StoryRender.ts index 01d8cecebfd0..cb1d71e46930 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/render/StoryRender.ts @@ -15,6 +15,7 @@ import { } from '@storybook/store'; import { Channel } from '@storybook/addons'; import { STORY_RENDER_PHASE_CHANGED, STORY_RENDERED } from '@storybook/core-events'; +import { Render, RenderType } from './Render'; const { AbortController } = global; @@ -47,18 +48,6 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); -export type RenderType = 'story' | 'docs'; -export interface Render { - type: RenderType; - id: StoryId; - story?: Story; - isPreparing: () => boolean; - disableKeyListeners: boolean; - teardown?: (options: { viewModeChanged: boolean }) => Promise; - torndown: boolean; - renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise; -} - export class StoryRender implements Render { public type: RenderType = 'story'; @@ -122,8 +111,12 @@ export class StoryRender implements Render) { - return other && this.id === other.id && this.story && this.story === other.story; + isEqual(other: Render): boolean { + return !!( + this.id === other.id && + this.story && + this.story === (other as StoryRender).story + ); } isPreparing() { diff --git a/lib/preview-web/src/render/TemplateDocsRender.ts b/lib/preview-web/src/render/TemplateDocsRender.ts new file mode 100644 index 000000000000..213d34570312 --- /dev/null +++ b/lib/preview-web/src/render/TemplateDocsRender.ts @@ -0,0 +1,103 @@ +import { AnyFramework, StoryId } from '@storybook/csf'; +import { Story, CSFFile } from '@storybook/store'; +import { DOCS_RENDERED } from '@storybook/core-events'; + +import { Render, RenderType } from './Render'; +import type { DocsContextProps, DocsRenderFunction } from '../types'; +import { AbstractDocsRender } from './AbstractDocsRender'; + +export class TemplateDocsRender< + TFramework extends AnyFramework +> extends AbstractDocsRender { + public type: RenderType = 'docs'; + + private csfFile?: CSFFile; + + public story?: Story; + + public teardown?: (options: { viewModeChanged?: boolean }) => Promise; + + public torndown = false; + + async prepare() { + this.preparing = true; + this.csfFile = await this.store.loadCSFFileByStoryId(this.id); + + // We use the first ("primary") story from the CSF as the "current" story on the context. + // - When rendering "true" CSF files, this is for back-compat, where templates may expect + // a story to be current (even though now we render a separate docs entry from the stories) + // - when rendering a "docs only" (story) id, this will end up being the same story as + // this.id, as such "CSF files" have only one story + this.story = this.store.storyFromCSFFile({ + storyId: Object.keys(this.csfFile.stories)[0], + csfFile: this.csfFile, + }); + this.preparing = false; + } + + isEqual(other: Render): boolean { + return !!( + this.id === other.id && + this.story && + this.story === (other as TemplateDocsRender).story + ); + } + + async getDocsContext( + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ): Promise> { + const { title, name } = this.entry; + + const { csfFile } = this; + if (!csfFile || !this.story) throw new Error(`Cannot get docs context before preparing`); + + const componentStories = () => this.store.componentStoriesFromCSFFile({ csfFile }); + const storyById = (storyId: StoryId) => this.store.storyFromCSFFile({ storyId, csfFile }); + + const storyIdByModuleExport = () => { + // NOTE: we could implement this easily enough by checking all the component stories + throw new Error('`storyIdByModuleExport` not available for legacy docs files.'); + }; + + return { + // TODO + type: 'legacy', + // NOTE: we use the id of the loaded story for reasons discussed in .prepare() above + id: this.story.id, + title, + name, + renderStoryToElement, + loadStory: this.loadStory, + getStoryContext: this.getStoryContext, + componentStories, + storyIdByModuleExport, + storyById, + setMeta: () => {}, + }; + } + + async render() { + if (!this.story || !this.docsContext || !this.canvasElement) + throw new Error('DocsRender not ready to render'); + + const { docs } = this.story?.parameters || {}; + + if (!docs) + throw new Error( + `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` + ); + + const renderer = await docs.renderer(); + (renderer.render as DocsRenderFunction)( + this.docsContext, + docs, + this.canvasElement, + () => this.channel.emit(DOCS_RENDERED, this.id) + ); + this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { + if (!viewModeChanged || !this.canvasElement) return; + renderer.unmount(this.canvasElement); + this.torndown = true; + }; + } +}