react-carousel-query
Version:
A infinite carousel component made with react that handles the pagination for you.
467 lines (389 loc) • 15.7 kB
JavaScript
import deprecate from 'util-deprecate';
import dedent from 'ts-dedent';
import global from 'global';
import { CURRENT_STORY_WAS_SET, IGNORED_EXCEPTION, PRELOAD_STORIES, PREVIEW_KEYDOWN, SET_CURRENT_STORY, SET_STORIES, STORY_ARGS_UPDATED, STORY_CHANGED, STORY_ERRORED, STORY_MISSING, STORY_PREPARED, STORY_RENDER_PHASE_CHANGED, STORY_SPECIFIED, STORY_THREW_EXCEPTION, STORY_UNCHANGED, UPDATE_QUERY_PARAMS } from '@storybook/core-events';
import { logger } from '@storybook/client-logger';
import { Preview } from './Preview';
import { UrlStore } from './UrlStore';
import { WebView } from './WebView';
import { PREPARE_ABORTED, StoryRender } from './StoryRender';
import { DocsRender } from './DocsRender';
const {
window: globalWindow
} = global;
function focusInInput(event) {
const target = event.target;
return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null;
}
export class PreviewWeb extends Preview {
constructor(urlStore = new UrlStore(), webview = new WebView()) {
super();
this.urlStore = void 0;
this.view = void 0;
this.previewEntryError = void 0;
this.currentSelection = void 0;
this.currentRender = void 0;
this.view = webview;
this.urlStore = urlStore; // Add deprecated APIs for back-compat
// @ts-ignore
this.storyStore.getSelection = deprecate(() => this.urlStore.selection, dedent`
\`__STORYBOOK_STORY_STORE__.getSelection()\` is deprecated and will be removed in 7.0.
To get the current selection, use the \`useStoryContext()\` hook from \`@storybook/addons\`.
`);
}
setupListeners() {
super.setupListeners();
globalWindow.onkeydown = this.onKeydown.bind(this);
this.channel.on(SET_CURRENT_STORY, this.onSetCurrentStory.bind(this));
this.channel.on(UPDATE_QUERY_PARAMS, this.onUpdateQueryParams.bind(this));
this.channel.on(PRELOAD_STORIES, this.onPreloadStories.bind(this));
}
initializeWithProjectAnnotations(projectAnnotations) {
return super.initializeWithProjectAnnotations(projectAnnotations).then(() => this.setInitialGlobals());
}
async setInitialGlobals() {
const {
globals
} = this.urlStore.selectionSpecifier || {};
if (globals) {
this.storyStore.globals.updateFromPersisted(globals);
}
this.emitGlobals();
} // If initialization gets as far as the story index, this function runs.
initializeWithStoryIndex(storyIndex) {
return super.initializeWithStoryIndex(storyIndex).then(() => {
var _global$FEATURES;
if (!((_global$FEATURES = global.FEATURES) !== null && _global$FEATURES !== void 0 && _global$FEATURES.storyStoreV7)) {
this.channel.emit(SET_STORIES, this.storyStore.getSetStoriesPayload());
}
return this.selectSpecifiedStory();
});
} // Use the selection specifier to choose a story, then render it
async selectSpecifiedStory() {
if (!this.urlStore.selectionSpecifier) {
this.renderMissingStory();
return;
}
const {
storySpecifier,
viewMode,
args
} = this.urlStore.selectionSpecifier;
const storyId = this.storyStore.storyIndex.storyIdFromSpecifier(storySpecifier);
if (!storyId) {
if (storySpecifier === '*') {
this.renderStoryLoadingException(storySpecifier, new Error(dedent`
Couldn't find any stories in your Storybook.
- Please check your stories field of your main.js config.
- Also check the browser console and terminal for error messages.
`));
} else {
this.renderStoryLoadingException(storySpecifier, new Error(dedent`
Couldn't find story matching '${storySpecifier}'.
- Are you sure a story with that id exists?
- Please check your stories field of your main.js config.
- Also check the browser console and terminal for error messages.
`));
}
return;
}
this.urlStore.setSelection({
storyId,
viewMode
});
this.channel.emit(STORY_SPECIFIED, this.urlStore.selection);
this.channel.emit(CURRENT_STORY_WAS_SET, this.urlStore.selection);
await this.renderSelection({
persistedArgs: args
});
} // EVENT HANDLERS
// This happens when a config file gets reloaded
async onGetProjectAnnotationsChanged({
getProjectAnnotations
}) {
await super.onGetProjectAnnotationsChanged({
getProjectAnnotations
});
this.renderSelection();
} // This happens when a glob gets HMR-ed
async onStoriesChanged({
importFn,
storyIndex
}) {
var _global$FEATURES2;
super.onStoriesChanged({
importFn,
storyIndex
});
if (!((_global$FEATURES2 = global.FEATURES) !== null && _global$FEATURES2 !== void 0 && _global$FEATURES2.storyStoreV7)) {
this.channel.emit(SET_STORIES, await this.storyStore.getSetStoriesPayload());
}
if (this.urlStore.selection) {
await this.renderSelection();
} else {
// Our selection has never applied before, but maybe it does now, let's try!
await this.selectSpecifiedStory();
}
}
onKeydown(event) {
var _this$currentRender;
if (!((_this$currentRender = this.currentRender) !== null && _this$currentRender !== void 0 && _this$currentRender.disableKeyListeners) && !focusInInput(event)) {
// We have to pick off the keys of the event that we need on the other side
const {
altKey,
ctrlKey,
metaKey,
shiftKey,
key,
code,
keyCode
} = event;
this.channel.emit(PREVIEW_KEYDOWN, {
event: {
altKey,
ctrlKey,
metaKey,
shiftKey,
key,
code,
keyCode
}
});
}
}
onSetCurrentStory(selection) {
this.urlStore.setSelection(Object.assign({
viewMode: 'story'
}, selection));
this.channel.emit(CURRENT_STORY_WAS_SET, this.urlStore.selection);
this.renderSelection();
}
onUpdateQueryParams(queryParams) {
this.urlStore.setQueryParams(queryParams);
}
async onUpdateGlobals({
globals
}) {
super.onUpdateGlobals({
globals
});
if (this.currentRender instanceof DocsRender) await this.currentRender.rerender(true);
}
async onUpdateArgs({
storyId,
updatedArgs
}) {
super.onUpdateArgs({
storyId,
updatedArgs
}); // NOTE: we aren't checking to see the story args are targetted at the "right" story.
// This is because we may render >1 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(false);
}
async onPreloadStories(ids) {
await Promise.all(ids.map(id => this.storyStore.loadStory({
storyId: id
})));
} // RENDERING
// We can either have:
// - a story selected in "story" viewMode,
// in which case we render it to the root element, OR
// - a story selected in "docs" viewMode,
// in which case we render the docsPage for that story
async renderSelection({
persistedArgs
} = {}) {
var _this$currentSelectio, _this$currentSelectio2, _lastRender, _global$FEATURES3;
const {
selection
} = this.urlStore;
if (!selection) {
throw new Error('Cannot render story as no selection was made');
}
const {
storyId
} = selection;
const storyIdChanged = ((_this$currentSelectio = this.currentSelection) === null || _this$currentSelectio === void 0 ? void 0 : _this$currentSelectio.storyId) !== storyId;
const viewModeChanged = ((_this$currentSelectio2 = this.currentSelection) === null || _this$currentSelectio2 === void 0 ? void 0 : _this$currentSelectio2.viewMode) !== selection.viewMode; // Show a spinner while we load the next story
if (selection.viewMode === 'story') {
this.view.showPreparingStory({
immediate: viewModeChanged
});
} else {
this.view.showPreparingDocs();
}
const lastSelection = this.currentSelection;
let lastRender = this.currentRender; // If the last render is still preparing, let's drop it right now. Either
// (a) it is a different story, which means we would drop it later, OR
// (b) it is the *same* story, in which case we will resolve our own .prepare() at the
// same moment anyway, and we should just "take over" the rendering.
// (We can't tell which it is yet, because it is possible that an HMR is going on and
// even though the storyId is the same, the story itself is not).
if ((_lastRender = lastRender) !== null && _lastRender !== void 0 && _lastRender.isPreparing()) {
await this.teardownRender(lastRender);
lastRender = null;
}
const storyRender = new StoryRender(this.channel, this.storyStore, (...args) => {
// At the start of renderToDOM we make the story visible (see note in WebView)
this.view.showStoryDuringRender();
return this.renderToDOM(...args);
}, this.mainStoryCallbacks(storyId), storyId, 'story'); // We need to store this right away, so if the story changes during
// the async `.prepare()` below, we can (potentially) cancel it
this.currentSelection = selection; // Note this may be replaced by a docsRender after preparing
this.currentRender = storyRender;
try {
await storyRender.prepare();
} catch (err) {
if (err !== PREPARE_ABORTED) {
// We are about to render an error so make sure the previous story is
// no longer rendered.
await this.teardownRender(lastRender);
this.renderStoryLoadingException(storyId, err);
}
return;
}
const implementationChanged = !storyIdChanged && !storyRender.isEqual(lastRender);
if (persistedArgs) this.storyStore.args.updateFromPersisted(storyRender.story, persistedArgs);
const {
parameters,
initialArgs,
argTypes,
args
} = storyRender.context(); // Don't re-render the story if nothing has changed to justify it
if (lastRender && !storyIdChanged && !implementationChanged && !viewModeChanged) {
this.currentRender = lastRender;
this.channel.emit(STORY_UNCHANGED, storyId);
this.view.showMain();
return;
} // Wait for the previous render to leave the page. NOTE: this will wait to ensure anything async
// is properly aborted, which (in some cases) can lead to the whole screen being refreshed.
await this.teardownRender(lastRender, {
viewModeChanged
}); // If we are rendering something new (as opposed to re-rendering the same or first story), emit
if (lastSelection && (storyIdChanged || viewModeChanged)) {
this.channel.emit(STORY_CHANGED, storyId);
}
if ((_global$FEATURES3 = global.FEATURES) !== null && _global$FEATURES3 !== void 0 && _global$FEATURES3.storyStoreV7) {
this.channel.emit(STORY_PREPARED, {
id: storyId,
parameters,
initialArgs,
argTypes,
args
});
} // For v6 mode / compatibility
// If the implementation changed, or args were persisted, the args may have changed,
// and the STORY_PREPARED event above may not be respected.
if (implementationChanged || persistedArgs) {
this.channel.emit(STORY_ARGS_UPDATED, {
storyId,
args
});
}
if (selection.viewMode === 'docs' || parameters.docsOnly) {
this.currentRender = DocsRender.fromStoryRender(storyRender);
this.currentRender.renderToElement(this.view.prepareForDocs(), this.renderStoryToElement.bind(this));
} else {
this.storyRenders.push(storyRender);
this.currentRender.renderToElement(this.view.prepareForStory(storyRender.story));
}
} // Used by docs' modernInlineRender to render a story to a given element
// Note this short-circuits the `prepare()` phase of the StoryRender,
// main to be consistent with the previous behaviour. In the future,
// we will change it to go ahead and load the story, which will end up being
// "instant", although async.
renderStoryToElement(story, element) {
const render = new StoryRender(this.channel, this.storyStore, this.renderToDOM, this.inlineStoryCallbacks(story.id), story.id, 'docs', story);
render.renderToElement(element);
this.storyRenders.push(render);
return async () => {
await this.teardownRender(render);
};
}
async teardownRender(render, {
viewModeChanged
} = {}) {
this.storyRenders = this.storyRenders.filter(r => r !== render);
await (render === null || render === void 0 ? void 0 : render.teardown({
viewModeChanged
}));
} // API
async extract(options) {
var _global$FEATURES4;
if (this.previewEntryError) {
throw this.previewEntryError;
}
if (!this.storyStore.projectAnnotations) {
// In v6 mode, if your preview.js throws, we never get a chance to initialize the preview
// or store, and the error is simply logged to the browser console. This is the best we can do
throw new Error(dedent`Failed to initialize Storybook.
Do you have an error in your \`preview.js\`? Check your Storybook's browser console for errors.`);
}
if ((_global$FEATURES4 = global.FEATURES) !== null && _global$FEATURES4 !== void 0 && _global$FEATURES4.storyStoreV7) {
await this.storyStore.cacheAllCSFFiles();
}
return this.storyStore.extract(options);
} // UTILITIES
mainStoryCallbacks(storyId) {
return {
showMain: () => this.view.showMain(),
showError: err => this.renderError(storyId, err),
showException: err => this.renderException(storyId, err)
};
}
inlineStoryCallbacks(storyId) {
return {
showMain: () => {},
showError: err => logger.error(`Error rendering docs story (${storyId})`, err),
showException: err => logger.error(`Error rendering docs story (${storyId})`, err)
};
}
renderPreviewEntryError(reason, err) {
super.renderPreviewEntryError(reason, err);
this.view.showErrorDisplay(err);
}
renderMissingStory() {
this.view.showNoPreview();
this.channel.emit(STORY_MISSING);
}
renderStoryLoadingException(storySpecifier, err) {
logger.error(`Unable to load story '${storySpecifier}':`);
logger.error(err);
this.view.showErrorDisplay(err);
this.channel.emit(STORY_MISSING, storySpecifier);
} // renderException is used if we fail to render the story and it is uncaught by the app layer
renderException(storyId, err) {
this.channel.emit(STORY_THREW_EXCEPTION, err);
this.channel.emit(STORY_RENDER_PHASE_CHANGED, {
newPhase: 'errored',
storyId
}); // Ignored exceptions exist for control flow purposes, and are typically handled elsewhere.
if (err !== IGNORED_EXCEPTION) {
this.view.showErrorDisplay(err);
logger.error(`Error rendering story '${storyId}':`);
logger.error(err);
}
} // renderError is used by the various app layers to inform the user they have done something
// wrong -- for instance returned the wrong thing from a story
renderError(storyId, {
title,
description
}) {
logger.error(`Error rendering story ${title}: ${description}`);
this.channel.emit(STORY_ERRORED, {
title,
description
});
this.channel.emit(STORY_RENDER_PHASE_CHANGED, {
newPhase: 'errored',
storyId
});
this.view.showErrorDisplay({
message: title,
stack: description
});
}
}