-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(react-storybook-addon): add Copy as Markdown button to FluentDocsPage #35255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
859d956
8e83c6e
edd3a58
8a0252d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,6 +7,7 @@ | |
"build-storybook": "storybook build -o ./dist/storybook --docs", | ||
"build-storybook:docsite": "cross-env DEPLOY_PATH=/charts/ storybook build -o ./dist/storybook --docs", | ||
"postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs", | ||
"postbuild-storybook:docsite": "yarn rewrite-title && yarn generate-llms-docs", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the postbuild hook didn't run for the new deployment |
||
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI Charts v9' --distPath ./dist/storybook", | ||
"generate-llms-docs": "yarn storybook-llms-extractor --distPath ./dist/storybook --summaryBaseUrl \"https://storybooks.fluentui.dev/charts/\" --summaryTitle \"Fluent UI Charts v9\" --summaryDescription \"Fluent UI React charts is a set of modern, accessible, interactive, lightweight and highly customizable visualization library representing the Microsoft design system. These charts are used across 100s of projects inside Microsoft across Microsoft 365, Copilot and Azure.\"", | ||
"clean": "just-scripts clean", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,8 +6,9 @@ | |
"scripts": { | ||
"build-storybook": "cross-env NODE_OPTIONS=--max_old_space_size=3072 storybook build -o ./dist/storybook --docs", | ||
"build-storybook:react": "cross-env NODE_OPTIONS=--max_old_space_size=3072 DEPLOY_PATH=/react/ storybook build -o ./dist/react --docs", | ||
"postbuild-storybook": "yarn rewrite-title && yarn generate-llms-docs", | ||
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9' --distPath ./dist/storybook", | ||
"postbuild-storybook": "yarn rewrite-title --distPath ./dist/storybook && yarn generate-llms-docs", | ||
"postbuild-storybook:react": "yarn rewrite-title --distPath ./dist/react && cross-env DEPLOY_PATH=/react/ yarn generate-llms-docs", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the same as above, but here we're passing the env var |
||
"rewrite-title": "node -r ../../scripts/ts-node/src/register ../../scripts/storybook/src/scripts/rewrite-title.ts --title 'Fluent UI React v9'", | ||
"generate-llms-docs": "yarn storybook-llms-extractor --config storybook-llms.config.js", | ||
"clean": "just-scripts clean", | ||
"code-style": "just-scripts code-style", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "minor", | ||
"comment": "feat: add Copy as Markdown button to FluentDocsPage", | ||
"packageName": "@fluentui/react-storybook-addon", | ||
"email": "[email protected]", | ||
"dependentChangeType": "patch" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,228 @@ | ||
import * as React from 'react'; | ||
import { SplitButton, type MenuButtonProps } from '@fluentui/react-button'; | ||
import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-menu'; | ||
import { Spinner } from '@fluentui/react-spinner'; | ||
import { Toast, Toaster, ToastTitle, useToastController } from '@fluentui/react-toast'; | ||
import { useId } from '@fluentui/react-utilities'; | ||
import { makeStyles } from '@griffel/react'; | ||
import { bundleIcon, MarkdownFilled, MarkdownRegular } from '@fluentui/react-icons'; | ||
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; | ||
|
||
const MarkdownIcon = bundleIcon(MarkdownFilled, MarkdownRegular); | ||
|
||
const useStyles = makeStyles({ | ||
button: { | ||
marginInlineStart: 'auto', | ||
}, | ||
}); | ||
|
||
export interface CopyAsMarkdownProps { | ||
/** The Storybook story ID used to generate the markdown URL */ | ||
storyId?: string; | ||
} | ||
|
||
/** | ||
* A button that allows users to copy the current page as markdown to their clipboard or view it in a new tab. | ||
* The markdown content is fetched from the Storybook API and cached for subsequent requests. | ||
*/ | ||
export const CopyAsMarkdownButton: React.FC<CopyAsMarkdownProps> = ({ storyId = '' }) => { | ||
const { targetDocument } = useFluent(); | ||
const targetWindow = targetDocument?.defaultView; | ||
const styles = useStyles(); | ||
const toastId = useId('copy-toast'); | ||
const toasterId = useId('toaster'); | ||
const { dispatchToast, updateToast } = useToastController(toasterId); | ||
|
||
// Cache for the fetched markdown content to avoid redundant network requests | ||
const markdownContentCache = React.useRef<string | null>(null); | ||
|
||
// AbortController to track and cancel fetch requests | ||
const abortControllerRef = React.useRef<AbortController | null>(null); | ||
|
||
// Full URL to the markdown endpoint for this story | ||
const markdownUrl = React.useMemo(() => { | ||
return targetWindow ? convertStoryIdToMarkdownUrl(targetWindow, storyId) : ''; | ||
}, [storyId, targetWindow]); | ||
|
||
// Cleanup: abort pending requests on unmount | ||
React.useEffect(() => { | ||
return () => { | ||
abortControllerRef.current?.abort(); | ||
}; | ||
}, []); | ||
|
||
/** | ||
* Fetches the markdown content (with caching) and copies it to the clipboard. | ||
* Shows a toast notification with loading, success, or error states. | ||
* Skips the request if one is already in progress. | ||
*/ | ||
const copyPageContentToClipboard = React.useCallback(async () => { | ||
// Skip if a request is already in progress (abort controller exists and not aborted) | ||
if (abortControllerRef.current && !abortControllerRef.current.signal.aborted) { | ||
return; | ||
} | ||
|
||
// Ensure we have a window context to use for clipboard and fetch | ||
if (!targetWindow) { | ||
return; | ||
} | ||
|
||
// Create new AbortController for this request | ||
const abortController = new AbortController(); | ||
abortControllerRef.current = abortController; | ||
|
||
// Show loading toast that persists until updated | ||
dispatchToast( | ||
<Toast> | ||
<ToastTitle media={<Spinner />}>Copying page content...</ToastTitle> | ||
</Toast>, | ||
{ | ||
toastId, | ||
intent: 'info', | ||
timeout: -1, // Never auto-dismiss | ||
}, | ||
); | ||
|
||
try { | ||
// Use cached content if available, otherwise fetch from API | ||
if (!markdownContentCache.current) { | ||
markdownContentCache.current = await fetchMarkdownContent(targetWindow, markdownUrl, abortController.signal); | ||
} | ||
|
||
// Copy to clipboard | ||
await targetWindow?.navigator.clipboard.writeText(markdownContentCache.current); | ||
|
||
// Update toast to success | ||
updateToast({ | ||
content: ( | ||
<Toast> | ||
<ToastTitle>Page content copied to clipboard!</ToastTitle> | ||
</Toast> | ||
), | ||
intent: 'success', | ||
toastId, | ||
timeout: 3000, | ||
}); | ||
} catch (error) { | ||
// Don't show error if request was aborted | ||
if (abortController.signal.aborted) { | ||
return; | ||
} | ||
|
||
const errorMessage = error instanceof Error ? error.message : String(error); | ||
|
||
// Update toast to error | ||
updateToast({ | ||
content: ( | ||
<Toast> | ||
<ToastTitle>Failed to copy page content: {errorMessage}</ToastTitle> | ||
</Toast> | ||
), | ||
intent: 'error', | ||
toastId, | ||
timeout: 3000, | ||
}); | ||
} finally { | ||
// Clear the abort controller ref to allow new requests | ||
abortControllerRef.current = null; | ||
} | ||
}, [dispatchToast, updateToast, toastId, markdownUrl, targetWindow]); | ||
|
||
/** Opens the markdown content in a new browser tab */ | ||
const openInNewTab = React.useCallback(() => { | ||
targetWindow?.open(markdownUrl, '_blank'); | ||
}, [markdownUrl, targetWindow]); | ||
|
||
if (!storyId) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<> | ||
<Menu positioning="below-end"> | ||
<MenuTrigger disableButtonEnhancement> | ||
{(triggerProps: MenuButtonProps) => ( | ||
<SplitButton | ||
className={styles.button} | ||
menuButton={triggerProps} | ||
primaryActionButton={{ | ||
appearance: 'secondary', | ||
icon: <MarkdownIcon />, | ||
onClick: copyPageContentToClipboard, | ||
'aria-label': 'Copy page content as markdown to clipboard', | ||
}} | ||
aria-label="Copy page as markdown" | ||
> | ||
Copy Page | ||
</SplitButton> | ||
)} | ||
</MenuTrigger> | ||
|
||
<MenuPopover> | ||
<MenuList> | ||
<MenuItem icon={<MarkdownIcon />} onClick={openInNewTab}> | ||
View as Markdown | ||
</MenuItem> | ||
</MenuList> | ||
</MenuPopover> | ||
</Menu> | ||
<Toaster toasterId={toasterId} /> | ||
</> | ||
); | ||
}; | ||
|
||
/** | ||
* Regex pattern to remove the story variant suffix from Storybook story IDs. | ||
* @example "button--primary" -> "button" | ||
*/ | ||
const STORYBOOK_VARIANT_SUFFIX_PATTERN = /--\w+$/g; | ||
|
||
/** | ||
* Gets the base URL for fetching markdown content from the Storybook LLM endpoint. | ||
* Each story's markdown is available at: {BASE_URL}/{storyId}.txt | ||
* @param targetWindow - The window object to use for location access | ||
* @returns The base URL constructed from current location origin and pathname | ||
*/ | ||
function getStorybookMarkdownApiBaseUrl(targetWindow: Window): string { | ||
// Remove the [page].html file from pathname and append /llms/ | ||
const basePath = targetWindow.location.pathname.replace(/\/[^/]*\.html$/, ''); | ||
return `${targetWindow.location.origin}${basePath}/llms/`; | ||
} | ||
|
||
/** | ||
* Converts a Storybook story ID to a markdown URL. | ||
* @param targetWindow - The window object to use for location access | ||
* @param storyId - The Storybook story ID | ||
* @returns The full URL to the markdown endpoint for the story | ||
* @example "button--primary" -> "https://storybooks.fluentui.dev/llms/button.txt" | ||
*/ | ||
function convertStoryIdToMarkdownUrl(targetWindow: Window, storyId: string): string { | ||
return `${getStorybookMarkdownApiBaseUrl(targetWindow)}${storyId.replace(STORYBOOK_VARIANT_SUFFIX_PATTERN, '.txt')}`; | ||
} | ||
|
||
/** | ||
* Fetches markdown content from the Storybook API. | ||
* @param targetWindow - The window object to use for fetch access | ||
* @param url - The URL to fetch markdown content from | ||
* @param signal - Optional AbortSignal to cancel the request | ||
* @returns Promise resolving to the markdown text content | ||
* @throws Error if the fetch request fails or is aborted | ||
*/ | ||
async function fetchMarkdownContent( | ||
targetWindow: Window, | ||
url: string, | ||
signal: AbortSignal | undefined, | ||
): Promise<string> { | ||
const response = await targetWindow.fetch(url, { | ||
headers: { | ||
'Content-Type': 'text/plain', | ||
}, | ||
signal, | ||
}); | ||
|
||
if (!response.ok) { | ||
throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); | ||
} | ||
|
||
return response.text(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🕵🏾♀️ visual changes to review in the Visual Change Report
vr-tests-react-components/Drawer 1 screenshots
vr-tests-react-components/Positioning 2 screenshots
vr-tests-react-components/TagPicker 1 screenshots
There were 1 duplicate changes discarded. Check the build logs for more information.