diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index b43dd30519a3..edaeb88a9876 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -260,7 +260,6 @@ export const Preview: FC = ({ zoom={(z: number) => setScale(scale * z)} resetZoom={() => setScale(1)} storyId={!isLoading && childProps ? getStoryId(childProps, context) : undefined} - baseUrl="./iframe.html" /> )} diff --git a/code/addons/docs/src/blocks/components/Story.tsx b/code/addons/docs/src/blocks/components/Story.tsx index 5af081fce655..144d14445fba 100644 --- a/code/addons/docs/src/blocks/components/Story.tsx +++ b/code/addons/docs/src/blocks/components/Story.tsx @@ -2,17 +2,15 @@ import type { FunctionComponent } from 'react'; import React, { useEffect, useRef, useState } from 'react'; -import { ErrorFormatter, Loader, getStoryHref } from 'storybook/internal/components'; +import { ErrorFormatter, Loader } from 'storybook/internal/components'; import type { DocsContextProps, PreparedStory } from 'storybook/internal/types'; import { styled } from 'storybook/theming'; +import { getStoryHref } from '../getStoryHref'; import { IFrame } from './IFrame'; import { ZoomContext } from './ZoomContext'; -const { PREVIEW_URL } = globalThis; -const BASE_URL = PREVIEW_URL || 'iframe.html'; - interface CommonProps { story: PreparedStory; inline: boolean; @@ -98,7 +96,7 @@ const IFrameStory: FunctionComponent = ({ story, height = '500 key="iframe" id={`iframe--${story.id}`} title={story.name} - src={getStoryHref(BASE_URL, story.id, { viewMode: 'story' })} + src={getStoryHref(story.id, { viewMode: 'story' })} allowFullScreen scale={scale} style={{ diff --git a/code/addons/docs/src/blocks/components/Toolbar.tsx b/code/addons/docs/src/blocks/components/Toolbar.tsx index 18713fc474fc..afebccb47b68 100644 --- a/code/addons/docs/src/blocks/components/Toolbar.tsx +++ b/code/addons/docs/src/blocks/components/Toolbar.tsx @@ -1,12 +1,14 @@ import type { FC, SyntheticEvent } from 'react'; import React from 'react'; -import { Button, Toolbar as SharedToolbar, getStoryHref } from 'storybook/internal/components'; +import { Button, Toolbar as SharedToolbar } from 'storybook/internal/components'; import { ShareAltIcon, ZoomIcon, ZoomOutIcon, ZoomResetIcon } from '@storybook/icons'; import { styled } from 'storybook/theming'; +import { getStoryHref } from '../getStoryHref'; + interface ZoomProps { zoom: (val: number) => void; resetZoom: () => void; @@ -14,7 +16,6 @@ interface ZoomProps { interface EjectProps { storyId?: string; - baseUrl?: string; } interface BarProps { @@ -52,14 +53,7 @@ const IconPlaceholder = styled.div(({ theme }) => ({ animation: `${theme.animation.glow} 1.5s ease-in-out infinite`, })); -export const Toolbar: FC = ({ - isLoading, - storyId, - baseUrl, - zoom, - resetZoom, - ...rest -}) => ( +export const Toolbar: FC = ({ isLoading, storyId, zoom, resetZoom, ...rest }) => ( {isLoading ? ( @@ -111,7 +105,6 @@ export const Toolbar: FC = ({ ) : ( - baseUrl && storyId && ( diff --git a/code/addons/docs/src/blocks/getStoryHref.ts b/code/addons/docs/src/blocks/getStoryHref.ts new file mode 100644 index 000000000000..bde3cddbb505 --- /dev/null +++ b/code/addons/docs/src/blocks/getStoryHref.ts @@ -0,0 +1,15 @@ +// Only for internal use in addon-docs code, because the parent util in `core` cannot be imported. +// Unlike the parent util, this one only returns the preview URL. +export const getStoryHref = (storyId: string, additionalParams: Record = {}) => { + const baseUrl = globalThis.PREVIEW_URL || 'iframe.html'; + const [url, paramsStr] = baseUrl.split('?'); + const params = new URLSearchParams(paramsStr || ''); + + Object.entries(additionalParams).forEach(([key, value]) => { + params.set(key, value); + }); + + params.set('id', storyId); + + return `${url}?${params.toString()}`; +}; diff --git a/code/core/src/components/components/utils/getStoryHref.ts b/code/core/src/components/components/utils/getStoryHref.ts index 335bead96aa3..8784c8670667 100644 --- a/code/core/src/components/components/utils/getStoryHref.ts +++ b/code/core/src/components/components/utils/getStoryHref.ts @@ -1,3 +1,5 @@ +import { deprecate } from 'storybook/internal/client-logger'; + function parseQuery(queryString: string) { const query: Record = {}; const pairs = queryString.split('&'); @@ -9,11 +11,15 @@ function parseQuery(queryString: string) { return query; } +/** @deprecated Use the api.getStoryHrefs method instead */ export const getStoryHref = ( baseUrl: string, storyId: string, additionalParams: Record = {} ) => { + deprecate( + 'getStoryHref is deprecated and will be removed in Storybook 11, use the api.getStoryHrefs method instead' + ); const [url, paramsStr] = baseUrl.split('?'); const params = paramsStr ? { diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 57233fca2eaa..68e2e7cc6eb0 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -113,6 +113,7 @@ export interface API_Shortcuts { expandAll: API_KeyCollection; remount: API_KeyCollection; openInEditor: API_KeyCollection; + openInIsolation: API_KeyCollection; copyStoryLink: API_KeyCollection; // TODO: bring this back once we want to add shortcuts for this // copyStoryName: API_KeyCollection; @@ -152,6 +153,7 @@ export const defaultShortcuts: API_Shortcuts = Object.freeze({ expandAll: [controlOrMetaKey(), 'shift', 'ArrowDown'], remount: ['alt', 'R'], openInEditor: ['alt', 'shift', 'E'], + openInIsolation: ['alt', 'shift', 'I'], copyStoryLink: ['alt', 'shift', 'L'], // TODO: bring this back once we want to add shortcuts for this // copyStoryName: ['alt', 'shift', 'C'], @@ -247,6 +249,7 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { const { ui: { enableShortcuts }, storyId, + refId, } = store.getState(); if (!enableShortcuts) { return; @@ -397,6 +400,13 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } break; } + case 'openInIsolation': { + if (storyId) { + const { previewHref } = fullAPI.getStoryHrefs(storyId, { refId }); + window.open(previewHref, '_blank', 'noopener,noreferrer'); + } + break; + } // TODO: bring this back once we want to add shortcuts for this // case 'copyStoryName': { // const storyData = fullAPI.getCurrentStoryData(); @@ -406,7 +416,8 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { // break; // } case 'copyStoryLink': { - copy(window.location.href); + const { managerHref } = fullAPI.getStoryHrefs(storyId, { refId }); + copy(managerHref); break; } default: diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 2eda0d3c4752..c37e95537bf3 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -7,17 +7,16 @@ import { } from 'storybook/internal/core-events'; import { buildArgsParam, queryFromLocation } from 'storybook/internal/router'; import type { NavigateOptions } from 'storybook/internal/router'; -import type { API_Layout, API_UI, Args } from 'storybook/internal/types'; +import type { API_Layout, API_UI, API_ViewMode, Args } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { dequal as deepEqual } from 'dequal'; +import { stringify } from 'picoquery'; import type { ModuleArgs, ModuleFn } from '../lib/types'; import { defaultLayoutState } from './layout'; -const { window: globalWindow } = global; - export interface SubState { customQueryParams: QueryParams; } @@ -33,6 +32,24 @@ const parseBoolean = (value: string) => { return undefined; }; +const parseSerializedParam = (param: string) => + Object.fromEntries( + param + .split(';') + .map((pair) => pair.split(':')) + // Encoding values ensures we don't break already encoded args/globals but also don't encode our own special characters like ; and :. + .map(([key, value]) => [key, encodeURIComponent(value)]) + .filter(([key, value]) => key && value) + ); + +const mergeSerializedParams = (params: string, extraParams: string) => { + const pairs = parseSerializedParam(params); + const extra = parseSerializedParam(extraParams); + return Object.entries({ ...pairs, ...extra }) + .map(([key, value]) => `${key}:${value}`) + .join(';'); +}; + // Initialize the state based on the URL. // NOTE: // Although we don't change the URL when you change the state, we do support setting initial state @@ -121,6 +138,33 @@ export interface SubAPI { * @returns {void} */ navigateUrl: (url: string, options: NavigateOptions) => void; + /** + * Get the manager and preview hrefs for a story. + * + * @param {string} storyId - The ID of the story to get the URL for. + * @param {Object} options - Options for the URL. + * @param {string} [options.base] - Return an absolute href based on the current origin or network + * address. + * @param {boolean} [options.inheritArgs] - Inherit args from the current URL. If storyId matches + * current story, inheritArgs defaults to true. + * @param {boolean} [options.inheritGlobals] - Inherit globals from the current URL. Defaults to + * true. + * @param {QueryParams} [options.queryParams] - Query params to add to the URL. + * @param {string} [options.refId] - ID of the ref to get the URL for (for composed Storybooks) + * @param {string} [options.viewMode] - The view mode to use, defaults to 'story'. + * @returns {Object} Manager and preview hrefs for the story. + */ + getStoryHrefs( + storyId: string, + options?: { + base?: 'origin' | 'network'; + inheritArgs?: boolean; + inheritGlobals?: boolean; + queryParams?: QueryParams; + refId?: string; + viewMode?: API_ViewMode; + } + ): { managerHref: string; previewHref: string }; /** * Get the value of a query parameter from the current URL. * @@ -183,6 +227,54 @@ export const init: ModuleFn = (moduleArgs) => { }; const api: SubAPI = { + getStoryHrefs(storyId, options = {}) { + const { id: currentStoryId, refId: currentRefId } = fullAPI.getCurrentStoryData() ?? {}; + const isCurrentStory = storyId === currentStoryId && options.refId === currentRefId; + + const { customQueryParams, location, refs } = store.getState(); + const { + base, + inheritArgs = isCurrentStory, + inheritGlobals = true, + queryParams = {}, + refId, + viewMode = 'story', + } = options; + + if (refId && !refs[refId]) { + throw new Error(`Invalid refId: ${refId}`); + } + + const originAddress = global.window.location.origin + location.pathname; + const networkAddress = global.STORYBOOK_NETWORK_ADDRESS ?? originAddress; + const managerBase = + base === 'origin' ? originAddress : base === 'network' ? networkAddress : location.pathname; + const previewBase = refId + ? refs[refId].url + '/iframe.html' + : global.PREVIEW_URL || `${managerBase}iframe.html`; + + const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; + const { args = '', globals = '', ...otherParams } = queryParams; + let argsParam = inheritArgs + ? mergeSerializedParams(customQueryParams?.args ?? '', args) + : args; + let globalsParam = inheritGlobals + ? mergeSerializedParams(customQueryParams?.globals ?? '', globals) + : globals; + let customParams = stringify(otherParams, { + nesting: true, + nestingSyntax: 'js', + }); + + argsParam = argsParam && `&args=${argsParam}`; + globalsParam = globalsParam && `&globals=${globalsParam}`; + customParams = customParams && `&${customParams}`; + + return { + managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${refId}_` : ''}${storyId}${argsParam}${globalsParam}${customParams}`, + previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`, + }; + }, getQueryParam(key) { const { customQueryParams } = store.getState(); return customQueryParams ? customQueryParams[key] : undefined; @@ -253,11 +345,11 @@ export const init: ModuleFn = (moduleArgs) => { let handleOrId: any; provider.channel?.on(STORY_ARGS_UPDATED, () => { - if ('requestIdleCallback' in globalWindow) { + if ('requestIdleCallback' in global.window) { if (handleOrId) { - globalWindow.cancelIdleCallback(handleOrId); + global.window.cancelIdleCallback(handleOrId); } - handleOrId = globalWindow.requestIdleCallback(updateArgsParam, { timeout: 1000 }); + handleOrId = global.window.requestIdleCallback(updateArgsParam, { timeout: 1000 }); } else { if (handleOrId) { clearTimeout(handleOrId); diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index 349783223291..1b095ff0de11 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -6,11 +6,25 @@ import { UPDATE_QUERY_PARAMS, } from 'storybook/internal/core-events'; +import { global } from '@storybook/global'; + import EventEmitter from 'events'; import { init as initURL } from '../modules/url'; vi.mock('storybook/internal/client-logger'); +vi.mock('@storybook/global', () => ({ + global: { + window: { + location: { + hash: '', + href: 'http://localhost:6006', + origin: 'http://localhost:6006', + }, + }, + STORYBOOK_NETWORK_ADDRESS: 'http://192.168.1.1:6006/', + }, +})); const storyState = (storyId) => ({ path: `/story/${storyId}`, @@ -19,8 +33,6 @@ const storyState = (storyId) => ({ }); describe('initial state', () => { - const viewMode = 'story'; - describe('config query parameters', () => { it('handles full parameter', () => { const navigate = vi.fn(); @@ -236,3 +248,210 @@ describe('initModule', () => { ); }); }); + +describe('getStoryHrefs', () => { + let state = {}; + const store = { + setState: (change) => { + state = { ...state, ...change }; + }, + getState: () => state, + }; + + it('returns manager and preview URLs for a story', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); + }); + + it('retains args and globals from the URL', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=a:1&globals=b:2'); + expect(previewHref).toContain('&args=a:1&globals=b:2'); + }); + + it('retains args with special values', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:!null;b:!hex(f00);c:!undefined' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=a:!null;b:!hex(f00);c:!undefined'); + expect(previewHref).toContain('&args=a:!null;b:!hex(f00);c:!undefined'); + }); + + it('drops args but retains globals when changing stories', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--another-story'); + expect(managerHref).toEqual('/?path=/story/test--another-story&globals=b:2'); + expect(previewHref).toEqual('/iframe.html?id=test--another-story&viewMode=story&globals=b:2'); + }); + + it('supports disabling inheritance of args and globals', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + inheritArgs: false, + inheritGlobals: false, + }); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); + }); + + it('supports extra args and globals with merging', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1;b:2&globals=c:3;d:4' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { args: 'a:2;c:3', globals: 'd:5' }, + }); + expect(managerHref).toContain('&args=a:2;b:2;c:3&globals=c:3;d:5'); + expect(previewHref).toContain('&args=a:2;b:2;c:3&globals=c:3;d:5'); + }); + + it('supports additional query params, including nested objects', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { one: 1, foo: { bar: 'baz' } }, + }); + expect(managerHref).toContain('&args=a:1&globals=b:2&one=1&foo.bar=baz'); + expect(previewHref).toContain('&args=a:1&globals=b:2&one=1&foo.bar=baz'); + }); + + it('correctly preserves args and globals encoding', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=equal:g%3Dh&globals=ampersand:c%26d' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=equal:g%3Dh&globals=ampersand:c%26d'); + expect(previewHref).toContain('&args=equal:g%3Dh&globals=ampersand:c%26d'); + }); + + it('correctly encodes query params', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { equal: 'a=b', ampersand: 'c&d' }, + }); + expect(managerHref).toContain('&equal=a%3Db&ersand=c%26d'); + expect(previewHref).toContain('&equal=a%3Db&ersand=c%26d'); + }); + + it('supports returning absolute URLs using the base option', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const origin = api.getStoryHrefs('test--story', { base: 'origin' }); + expect(origin.managerHref).toContain('http://localhost:6006/?path='); + expect(origin.previewHref).toContain('http://localhost:6006/iframe.html'); + + const network = api.getStoryHrefs('test--story', { base: 'network' }); + expect(network.managerHref).toContain('http://192.168.1.1:6006/?path='); + expect(network.previewHref).toContain('http://192.168.1.1:6006/iframe.html'); + }); + + it('supports linking to a ref, dropping globals in preview', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + store.setState({ refs: { external: { url: 'https://sb.example.com' } } }); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { refId: 'external' }); + expect(managerHref).toEqual('/?path=/story/external_test--story&globals=b:2'); + expect(previewHref).toEqual( + 'https://sb.example.com/iframe.html?id=test--story&viewMode=story&refId=external' + ); + }); + + it('supports PREVIEW_URL override', () => { + global.PREVIEW_URL = 'https://custom.preview.url/'; + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('https://custom.preview.url/?id=test--story&viewMode=story'); + }); +}); diff --git a/code/core/src/manager/components/preview/FramesRenderer.tsx b/code/core/src/manager/components/preview/FramesRenderer.tsx index fd15d6e19a95..03a47e247676 100644 --- a/code/core/src/manager/components/preview/FramesRenderer.tsx +++ b/code/core/src/manager/components/preview/FramesRenderer.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import React, { Fragment, useRef } from 'react'; -import { Button, getStoryHref } from 'storybook/internal/components'; +import { Button } from 'storybook/internal/components'; import type { Combo } from 'storybook/manager-api'; import { Consumer } from 'storybook/manager-api'; @@ -9,7 +9,6 @@ import { Global, styled } from 'storybook/theming'; import type { CSSObject } from 'storybook/theming'; import { IFrame } from './Iframe'; -import { stringifyQueryParams } from './utils/stringifyQueryParams'; import type { FramesRendererProps } from './utils/types'; const getActive = (refId: FramesRendererProps['refId'], refs: FramesRendererProps['refs']) => { @@ -53,19 +52,15 @@ const styles: CSSObject = { }; export const FramesRenderer: FC = ({ + api, refs, scale, viewMode = 'story', refId, queryParams = {}, - baseUrl, storyId = '*', }) => { const version = refs[refId]?.version; - const stringifiedQueryParams = stringifyQueryParams({ - ...queryParams, - ...(version && { version }), - }); const active = getActive(refId, refs); const { current: frames } = useRef>({}); @@ -74,19 +69,21 @@ export const FramesRenderer: FC = ({ }, {}); if (!frames['storybook-preview-iframe']) { - frames['storybook-preview-iframe'] = getStoryHref(baseUrl, storyId, { - ...queryParams, - ...(version && { version }), + frames['storybook-preview-iframe'] = api.getStoryHrefs(storyId, { + queryParams: { ...queryParams, ...(version && { version }) }, + refId, viewMode, - }); + }).previewHref; } refsToLoad.forEach((ref) => { const id = `storybook-ref-${ref.id}`; - const existingUrl = frames[id]?.split('/iframe.html')[0]; - if (!existingUrl || ref.url !== existingUrl) { - const newUrl = `${ref.url}/iframe.html?id=${storyId}&viewMode=${viewMode}&refId=${ref.id}${stringifiedQueryParams}`; - frames[id] = newUrl; + if (!frames[id]?.startsWith(ref.url)) { + frames[id] = api.getStoryHrefs(storyId, { + queryParams: { ...queryParams, ...(version && { version }) }, + refId: ref.id, + viewMode, + }).previewHref; } }); diff --git a/code/core/src/manager/components/preview/Preview.tsx b/code/core/src/manager/components/preview/Preview.tsx index dc21a9699dda..2ffcaba4f939 100644 --- a/code/core/src/manager/components/preview/Preview.tsx +++ b/code/core/src/manager/components/preview/Preview.tsx @@ -20,6 +20,7 @@ import * as S from './utils/components'; import type { PreviewProps } from './utils/types'; const canvasMapper = ({ state, api }: Combo) => ({ + api, storyId: state.storyId, refId: state.refId, viewMode: state.viewMode, @@ -153,6 +154,7 @@ const Canvas: FC<{ return ( {({ + api, entry, refs, customCanvas, @@ -202,7 +204,7 @@ const Canvas: FC<{ customCanvas(storyId, viewMode, id, baseUrl, scale, queryParams) ) : ( ({ copyStoryLink: ['alt', 'shift', 'k'] }), + getShortcutKeys: () => ({ + copyStoryLink: ['alt', 'shift', 'l'], + openInIsolation: ['alt', 'shift', 'i'], + }), + getStoryHrefs: () => ({ + managerHref: '/?path=/story/manager-preview-tools-share--default', + previewHref: '/iframe.html?id=manager-preview-tools-share--default&viewMode=story', + }), }, } as any; diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx index 5517dec0b54c..b7dd9720cccc 100644 --- a/code/core/src/manager/components/preview/tools/share.tsx +++ b/code/core/src/manager/components/preview/tools/share.tsx @@ -1,43 +1,22 @@ import React, { useMemo, useState } from 'react'; -import { - Button, - PopoverProvider, - TooltipLinkList, - getStoryHref, -} from 'storybook/internal/components'; +import { Button, PopoverProvider, TooltipLinkList } from 'storybook/internal/components'; import type { Addon_BaseType } from 'storybook/internal/types'; import { global } from '@storybook/global'; -import { BugIcon, LinkIcon, ShareIcon } from '@storybook/icons'; +import { LinkIcon, ShareAltIcon, ShareIcon } from '@storybook/icons'; import copy from 'copy-to-clipboard'; import { QRCodeSVG as QRCode } from 'qrcode.react'; -import { Consumer, types, useStorybookApi } from 'storybook/manager-api'; -import type { Combo } from 'storybook/manager-api'; +import { Consumer, types } from 'storybook/manager-api'; +import type { API, Combo } from 'storybook/manager-api'; import { styled, useTheme } from 'storybook/theming'; import { Shortcut } from '../../../container/Menu'; -const { PREVIEW_URL, document } = global as any; - -const mapper = ({ state }: Combo) => { - const { storyId, refId, refs } = state; - const { location } = document; - // @ts-expect-error (non strict) - const ref = refs[refId]; - let baseUrl = `${location.origin}${location.pathname}`; - - if (!baseUrl.endsWith('/')) { - baseUrl += '/'; - } - - return { - refId, - baseUrl: ref ? `${ref.url}/iframe.html` : (PREVIEW_URL as string) || `${baseUrl}iframe.html`, - storyId, - queryParams: state.customQueryParams, - }; +const mapper = ({ api, state }: Combo) => { + const { storyId, refId } = state; + return { api, refId, storyId }; }; const QRContainer = styled.div(() => ({ @@ -77,28 +56,27 @@ const QRDescription = styled.div(({ theme }) => ({ color: theme.textMutedColor, })); -function ShareMenu({ - baseUrl, +const ShareMenu = React.memo(function ShareMenu({ + api, storyId, - queryParams, - qrUrl, - isDevelopment, + refId, }: { - baseUrl: string; + api: API; storyId: string; - queryParams: Record; - qrUrl: string; - isDevelopment: boolean; + refId: string | undefined; }) { - const api = useStorybookApi(); const shortcutKeys = api.getShortcutKeys(); const enableShortcuts = !!shortcutKeys; const [copied, setCopied] = useState(false); const copyStoryLink = shortcutKeys?.copyStoryLink; + const openInIsolation = shortcutKeys?.openInIsolation; const links = useMemo(() => { const copyTitle = copied ? 'Copied!' : 'Copy story link'; - const baseLinks = [ + const originHrefs = api.getStoryHrefs(storyId, { base: 'origin', refId }); + const networkHrefs = api.getStoryHrefs(storyId, { base: 'network', refId }); + + return [ [ { id: 'copy-link', @@ -106,7 +84,7 @@ function ShareMenu({ icon: , right: enableShortcuts ? : null, onClick: () => { - copy(window.location.href); + copy(originHrefs.managerHref); setCopied(true); setTimeout(() => setCopied(false), 2000); }, @@ -114,71 +92,58 @@ function ShareMenu({ { id: 'open-new-tab', title: 'Open in isolation mode', - icon: , - onClick: () => { - const href = getStoryHref(baseUrl, storyId, queryParams); - window.open(href, '_blank', 'noopener,noreferrer'); - }, + icon: , + right: enableShortcuts ? : null, + href: originHrefs.previewHref, + target: '_blank', + rel: 'noopener noreferrer', + }, + ], + [ + { + id: 'qr-section', + content: ( + + + + Scan to open + + {global.CONFIG_TYPE === 'DEVELOPMENT' + ? 'Device must be on the same network.' + : 'View story on another device.'} + + + + ), }, ], ]; + }, [api, storyId, refId, copied, enableShortcuts, copyStoryLink, openInIsolation]); - baseLinks.push([ - { - id: 'qr-section', - // @ts-expect-error (non strict) - content: ( - - - - Scan to open - - {isDevelopment - ? 'Device must be on the same network.' - : 'View story on another device.'} - - - - ), - }, - ]); - - return baseLinks; - }, [baseUrl, storyId, queryParams, copied, qrUrl, enableShortcuts, copyStoryLink, isDevelopment]); - - return ; -} + return ; +}); export const shareTool: Addon_BaseType = { title: 'share', id: 'share', type: types.TOOL, match: ({ viewMode, tabId }) => viewMode === 'story' && !tabId, - render: () => { - return ( - - {({ baseUrl, storyId, queryParams }) => { - const isDevelopment = global.CONFIG_TYPE === 'DEVELOPMENT'; - const storyUrl = global.STORYBOOK_NETWORK_ADDRESS - ? new URL(window.location.search, global.STORYBOOK_NETWORK_ADDRESS).href - : window.location.href; - - return storyId ? ( - - } - > - - - ) : null; - }} - - ); - }, + render: () => ( + + {({ api, storyId, refId }) => + storyId ? ( + } + > + + + ) : null + } + + ), }; diff --git a/code/core/src/manager/components/preview/utils/stringifyQueryParams.tsx b/code/core/src/manager/components/preview/utils/stringifyQueryParams.tsx deleted file mode 100644 index dea058659eac..000000000000 --- a/code/core/src/manager/components/preview/utils/stringifyQueryParams.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { stringify } from 'picoquery'; - -export const stringifyQueryParams = (queryParams: Record) => { - const result = stringify(queryParams); - if (result === '') { - return ''; - } - - return `&${result}`; -}; diff --git a/code/core/src/manager/components/preview/utils/types.tsx b/code/core/src/manager/components/preview/utils/types.tsx index e713295a6c21..1e6b087b453a 100644 --- a/code/core/src/manager/components/preview/utils/types.tsx +++ b/code/core/src/manager/components/preview/utils/types.tsx @@ -49,6 +49,7 @@ export type CustomCanvasRenderer = ( ) => ReactElement | null; export interface FramesRendererProps { + api: API; entry: LeafEntry; storyId: StoryId; refId: string; diff --git a/code/core/src/manager/settings/defaultShortcuts.tsx b/code/core/src/manager/settings/defaultShortcuts.tsx index 2c7fa19e6d2e..b892f742ddd5 100644 --- a/code/core/src/manager/settings/defaultShortcuts.tsx +++ b/code/core/src/manager/settings/defaultShortcuts.tsx @@ -21,6 +21,7 @@ export const defaultShortcuts: State['shortcuts'] = { expandAll: ['ctrl', 'shift', 'ArrowDown'], remount: ['alt', 'R'], openInEditor: ['alt', 'shift', 'E'], + openInIsolation: ['alt', 'shift', 'I'], copyStoryLink: ['alt', 'shift', 'L'], // TODO: bring this back once we want to add shortcuts for this // copyStoryName: ['alt', 'shift', 'C'], diff --git a/code/core/src/manager/settings/shortcuts.tsx b/code/core/src/manager/settings/shortcuts.tsx index 9713715f3155..ebb0c4951a4c 100644 --- a/code/core/src/manager/settings/shortcuts.tsx +++ b/code/core/src/manager/settings/shortcuts.tsx @@ -133,6 +133,7 @@ const shortcutLabels = { expandAll: 'Expand all items on sidebar', remount: 'Reload story', openInEditor: 'Open story in editor', + openInIsolation: 'Open story in isolation', copyStoryLink: 'Copy story link to clipboard', // TODO: bring this back once we want to add shortcuts for this // copyStoryName: 'Copy story name to clipboard', diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts index 7426e797d488..b82e449d0c25 100644 --- a/code/core/src/typings.d.ts +++ b/code/core/src/typings.d.ts @@ -15,6 +15,9 @@ declare var STORYBOOK_RENDERER: import('./types/modules/renderers').SupportedRen declare var STORYBOOK_HOOKS_CONTEXT: any; declare var STORYBOOK_CURRENT_TASK_LOG: undefined | null | Array; +declare var STORYBOOK_NETWORK_ADDRESS: string | undefined; +declare var PREVIEW_URL: string | undefined; + declare var __STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__: any; declare var __STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__: any; declare var __STORYBOOK_ADDONS_CHANNEL__: any;