|
| 1 | +import * as React from 'react'; |
| 2 | +import { |
| 3 | + makeStyles, |
| 4 | + Menu, |
| 5 | + MenuButtonProps, |
| 6 | + MenuItem, |
| 7 | + MenuList, |
| 8 | + MenuPopover, |
| 9 | + MenuTrigger, |
| 10 | + Spinner, |
| 11 | + SplitButton, |
| 12 | + Toast, |
| 13 | + Toaster, |
| 14 | + ToastTitle, |
| 15 | + useId, |
| 16 | + useToastController, |
| 17 | +} from '@fluentui/react-components'; |
| 18 | +import { bundleIcon, MarkdownFilled, MarkdownRegular } from '@fluentui/react-icons'; |
| 19 | + |
| 20 | +const MarkdownIcon = bundleIcon(MarkdownFilled, MarkdownRegular); |
| 21 | + |
| 22 | +const useStyles = makeStyles({ |
| 23 | + button: { |
| 24 | + marginInlineStart: 'auto', |
| 25 | + }, |
| 26 | +}); |
| 27 | + |
| 28 | +export interface CopyAsMarkdownProps { |
| 29 | + /** The Storybook story ID used to generate the markdown URL */ |
| 30 | + storyId?: string; |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * A button that allows users to copy the current page as markdown to their clipboard or view it in a new tab. |
| 35 | + * The markdown content is fetched from the Storybook API and cached for subsequent requests. |
| 36 | + */ |
| 37 | +export const CopyAsMarkdownButton: React.FC<CopyAsMarkdownProps> = ({ storyId = '' }) => { |
| 38 | + const styles = useStyles(); |
| 39 | + const toastId = useId('copy-toast'); |
| 40 | + const toasterId = useId('toaster'); |
| 41 | + const { dispatchToast, updateToast } = useToastController(toasterId); |
| 42 | + |
| 43 | + // Cache for the fetched markdown content to avoid redundant network requests |
| 44 | + const markdownContentCache = React.useRef<string | null>(null); |
| 45 | + |
| 46 | + // AbortController to track and cancel fetch requests |
| 47 | + const abortControllerRef = React.useRef<AbortController | null>(null); |
| 48 | + |
| 49 | + // Full URL to the markdown endpoint for this story |
| 50 | + const markdownUrl = React.useMemo(() => { |
| 51 | + return convertStoryIdToMarkdownUrl(storyId); |
| 52 | + }, [storyId]); |
| 53 | + |
| 54 | + // Cleanup: abort pending requests on unmount |
| 55 | + React.useEffect(() => { |
| 56 | + return () => { |
| 57 | + abortControllerRef.current?.abort(); |
| 58 | + }; |
| 59 | + }, []); |
| 60 | + |
| 61 | + /** |
| 62 | + * Fetches the markdown content (with caching) and copies it to the clipboard. |
| 63 | + * Shows a toast notification with loading, success, or error states. |
| 64 | + * Skips the request if one is already in progress. |
| 65 | + */ |
| 66 | + const copyPageContentToClipboard = React.useCallback(async () => { |
| 67 | + // Skip if a request is already in progress (abort controller exists and not aborted) |
| 68 | + if (abortControllerRef.current && !abortControllerRef.current.signal.aborted) { |
| 69 | + return; |
| 70 | + } |
| 71 | + |
| 72 | + // Create new AbortController for this request |
| 73 | + const abortController = new AbortController(); |
| 74 | + abortControllerRef.current = abortController; |
| 75 | + |
| 76 | + // Show loading toast that persists until updated |
| 77 | + dispatchToast( |
| 78 | + <Toast> |
| 79 | + <ToastTitle media={<Spinner />}>Copying page content...</ToastTitle> |
| 80 | + </Toast>, |
| 81 | + { |
| 82 | + toastId, |
| 83 | + intent: 'info', |
| 84 | + timeout: -1, // Never auto-dismiss |
| 85 | + }, |
| 86 | + ); |
| 87 | + |
| 88 | + try { |
| 89 | + // Use cached content if available, otherwise fetch from API |
| 90 | + if (!markdownContentCache.current) { |
| 91 | + markdownContentCache.current = await fetchMarkdownContent(markdownUrl, abortController.signal); |
| 92 | + } |
| 93 | + |
| 94 | + // Copy to clipboard |
| 95 | + await navigator.clipboard.writeText(markdownContentCache.current); |
| 96 | + |
| 97 | + // Update toast to success |
| 98 | + updateToast({ |
| 99 | + content: ( |
| 100 | + <Toast> |
| 101 | + <ToastTitle>Page content copied to clipboard!</ToastTitle> |
| 102 | + </Toast> |
| 103 | + ), |
| 104 | + intent: 'success', |
| 105 | + toastId, |
| 106 | + timeout: 3000, |
| 107 | + }); |
| 108 | + } catch (error) { |
| 109 | + // Don't show error if request was aborted |
| 110 | + if (abortController.signal.aborted) { |
| 111 | + return; |
| 112 | + } |
| 113 | + |
| 114 | + const errorMessage = error instanceof Error ? error.message : String(error); |
| 115 | + |
| 116 | + // Update toast to error |
| 117 | + updateToast({ |
| 118 | + content: ( |
| 119 | + <Toast> |
| 120 | + <ToastTitle>Failed to copy page content: {errorMessage}</ToastTitle> |
| 121 | + </Toast> |
| 122 | + ), |
| 123 | + intent: 'error', |
| 124 | + toastId, |
| 125 | + timeout: 3000, |
| 126 | + }); |
| 127 | + } finally { |
| 128 | + // Clear the abort controller ref to allow new requests |
| 129 | + abortControllerRef.current = null; |
| 130 | + } |
| 131 | + }, [dispatchToast, updateToast, toastId, markdownUrl]); |
| 132 | + |
| 133 | + /** Opens the markdown content in a new browser tab */ |
| 134 | + const openInNewTab = React.useCallback(() => { |
| 135 | + window.open(markdownUrl, '_blank'); |
| 136 | + }, [markdownUrl]); |
| 137 | + |
| 138 | + if (!storyId) { |
| 139 | + return null; |
| 140 | + } |
| 141 | + |
| 142 | + return ( |
| 143 | + <> |
| 144 | + <Menu positioning="below-end"> |
| 145 | + <MenuTrigger disableButtonEnhancement> |
| 146 | + {(triggerProps: MenuButtonProps) => ( |
| 147 | + <SplitButton |
| 148 | + className={styles.button} |
| 149 | + menuButton={triggerProps} |
| 150 | + primaryActionButton={{ |
| 151 | + appearance: 'secondary', |
| 152 | + icon: <MarkdownIcon />, |
| 153 | + onClick: copyPageContentToClipboard, |
| 154 | + 'aria-label': 'Copy page content as markdown to clipboard', |
| 155 | + }} |
| 156 | + aria-label="Copy page as markdown" |
| 157 | + > |
| 158 | + Copy Page |
| 159 | + </SplitButton> |
| 160 | + )} |
| 161 | + </MenuTrigger> |
| 162 | + |
| 163 | + <MenuPopover> |
| 164 | + <MenuList> |
| 165 | + <MenuItem icon={<MarkdownIcon />} onClick={openInNewTab}> |
| 166 | + View as Markdown |
| 167 | + </MenuItem> |
| 168 | + </MenuList> |
| 169 | + </MenuPopover> |
| 170 | + </Menu> |
| 171 | + <Toaster toasterId={toasterId} /> |
| 172 | + </> |
| 173 | + ); |
| 174 | +}; |
| 175 | + |
| 176 | +/** |
| 177 | + * Regex pattern to remove the story variant suffix from Storybook story IDs. |
| 178 | + * @example "button--primary" -> "button" |
| 179 | + */ |
| 180 | +const STORYBOOK_VARIANT_SUFFIX_PATTERN = /--\w+$/g; |
| 181 | + |
| 182 | +/** |
| 183 | + * Gets the base URL for fetching markdown content from the Storybook LLM endpoint. |
| 184 | + * Each story's markdown is available at: {BASE_URL}/{storyId}.txt |
| 185 | + * @returns The base URL constructed from current location origin and pathname |
| 186 | + */ |
| 187 | +function getStorybookMarkdownApiBaseUrl(): string { |
| 188 | + // Remove the [page].html file from pathname and append /llms/ |
| 189 | + const basePath = window.location.pathname.replace(/\/[^/]*\.html$/, ''); |
| 190 | + return `${window.location.origin}${basePath}/llms/`; |
| 191 | +} |
| 192 | + |
| 193 | +/** |
| 194 | + * Converts a Storybook story ID to a markdown URL. |
| 195 | + * @param storyId - The Storybook story ID |
| 196 | + * @returns The full URL to the markdown endpoint for the story |
| 197 | + * @example "button--primary" -> "https://storybooks.fluentui.dev/llms/button.txt" |
| 198 | + */ |
| 199 | +function convertStoryIdToMarkdownUrl(storyId: string): string { |
| 200 | + return `${getStorybookMarkdownApiBaseUrl()}${storyId.replace(STORYBOOK_VARIANT_SUFFIX_PATTERN, '.txt')}`; |
| 201 | +} |
| 202 | + |
| 203 | +/** |
| 204 | + * Fetches markdown content from the Storybook API. |
| 205 | + * @param url - The URL to fetch markdown content from |
| 206 | + * @param signal - Optional AbortSignal to cancel the request |
| 207 | + * @returns Promise resolving to the markdown text content |
| 208 | + * @throws Error if the fetch request fails or is aborted |
| 209 | + */ |
| 210 | +async function fetchMarkdownContent(url: string, signal?: AbortSignal): Promise<string> { |
| 211 | + const response = await fetch(url, { |
| 212 | + headers: { |
| 213 | + 'Content-Type': 'text/plain', |
| 214 | + }, |
| 215 | + signal, |
| 216 | + }); |
| 217 | + |
| 218 | + if (!response.ok) { |
| 219 | + throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); |
| 220 | + } |
| 221 | + |
| 222 | + return response.text(); |
| 223 | +} |
0 commit comments