codeSnippet.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import {Fragment, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import Prism from 'prismjs';
  4. import {Button} from 'sentry/components/button';
  5. import {IconCopy} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import {loadPrismLanguage} from 'sentry/utils/prism';
  9. interface CodeSnippetProps {
  10. children: string;
  11. className?: string;
  12. dark?: boolean;
  13. ['data-render-inline']?: boolean;
  14. /**
  15. * Makes the text of the element and its sub-elements not selectable.
  16. * Useful when loading parts of a code snippet, and
  17. * we wish to avoid users copying them manually.
  18. */
  19. disableUserSelection?: boolean;
  20. /**
  21. * Name of the file to be displayed at the top of the code snippet.
  22. */
  23. filename?: string;
  24. /**
  25. * Hides the copy button in the top right.
  26. */
  27. hideCopyButton?: boolean;
  28. /**
  29. * Adds an icon to the top right, next to the copy button.
  30. */
  31. icon?: React.ReactNode;
  32. /**
  33. * Controls whether the snippet wrapper has rounded corners.
  34. */
  35. isRounded?: boolean;
  36. language?: string;
  37. /**
  38. * Line numbers of the code that will be visually highlighted.
  39. */
  40. linesToHighlight?: number[];
  41. /**
  42. * Fires after the code snippet is highlighted and all DOM nodes are available
  43. * @param element The root element of the code snippet
  44. */
  45. onAfterHighlight?: (element: HTMLElement) => void;
  46. /**
  47. * Fires with the user presses the copy button.
  48. */
  49. onCopy?: (copiedCode: string) => void;
  50. /**
  51. * Fires when the user selects and copies code snippet manually
  52. */
  53. onSelectAndCopy?: () => void;
  54. /**
  55. * Fires when the user switches tabs.
  56. */
  57. onTabClick?: (tab: string) => void;
  58. selectedTab?: string;
  59. tabs?: {
  60. label: string;
  61. value: string;
  62. }[];
  63. }
  64. export function CodeSnippet({
  65. children,
  66. className,
  67. dark,
  68. 'data-render-inline': dataRenderInline,
  69. disableUserSelection,
  70. filename,
  71. hideCopyButton,
  72. language,
  73. linesToHighlight,
  74. icon,
  75. isRounded = true,
  76. onAfterHighlight,
  77. onCopy,
  78. onSelectAndCopy,
  79. onTabClick,
  80. selectedTab,
  81. tabs,
  82. }: CodeSnippetProps) {
  83. const ref = useRef<HTMLModElement | null>(null);
  84. // https://prismjs.com/plugins/line-highlight/
  85. useEffect(() => {
  86. if (linesToHighlight) {
  87. import('prismjs/plugins/line-highlight/prism-line-highlight');
  88. }
  89. }, [linesToHighlight]);
  90. useEffect(() => {
  91. const element = ref.current;
  92. if (!element) {
  93. return;
  94. }
  95. if (!language) {
  96. return;
  97. }
  98. if (language in Prism.languages) {
  99. Prism.highlightElement(element, false, () => onAfterHighlight?.(element));
  100. return;
  101. }
  102. loadPrismLanguage(language, {
  103. onLoad: () =>
  104. Prism.highlightElement(element, false, () => onAfterHighlight?.(element)),
  105. });
  106. }, [children, language, onAfterHighlight]);
  107. const [tooltipState, setTooltipState] = useState<'copy' | 'copied' | 'error'>('copy');
  108. const handleCopy = () => {
  109. navigator.clipboard
  110. .writeText(ref.current?.textContent ?? '')
  111. .then(() => {
  112. setTooltipState('copied');
  113. })
  114. .catch(() => {
  115. setTooltipState('error');
  116. });
  117. onCopy?.(children);
  118. };
  119. const hasTabs = tabs && tabs.length > 0;
  120. const hasSolidHeader = !!(filename || hasTabs);
  121. const tooltipTitle =
  122. tooltipState === 'copy'
  123. ? t('Copy')
  124. : tooltipState === 'copied'
  125. ? t('Copied')
  126. : t('Unable to copy');
  127. return (
  128. <Wrapper
  129. isRounded={isRounded}
  130. className={`${dark ? 'prism-dark ' : ''}${className ?? ''}`}
  131. data-render-inline={dataRenderInline}
  132. >
  133. <Header isSolid={hasSolidHeader}>
  134. {hasTabs && (
  135. <Fragment>
  136. <TabsWrapper>
  137. {tabs.map(({label, value}) => (
  138. <Tab
  139. type="button"
  140. isSelected={selectedTab === value}
  141. onClick={() => onTabClick?.(value)}
  142. key={value}
  143. >
  144. {label}
  145. </Tab>
  146. ))}
  147. </TabsWrapper>
  148. <FlexSpacer />
  149. </Fragment>
  150. )}
  151. {icon}
  152. {filename && <FileName>{filename}</FileName>}
  153. {!hasTabs && <FlexSpacer />}
  154. {!hideCopyButton && (
  155. <CopyButton
  156. type="button"
  157. size="xs"
  158. translucentBorder
  159. borderless
  160. onClick={handleCopy}
  161. title={tooltipTitle}
  162. tooltipProps={{delay: 0, isHoverable: false, position: 'left'}}
  163. onMouseLeave={() => setTooltipState('copy')}
  164. isAlwaysVisible={hasSolidHeader}
  165. >
  166. <IconCopy size="xs" />
  167. </CopyButton>
  168. )}
  169. </Header>
  170. <pre
  171. className={`language-${String(language)}`}
  172. data-line={linesToHighlight?.join(',')}
  173. >
  174. <Code
  175. ref={ref}
  176. className={`language-${String(language)}`}
  177. onCopy={onSelectAndCopy}
  178. disableUserSelection={disableUserSelection}
  179. >
  180. {children}
  181. </Code>
  182. </pre>
  183. </Wrapper>
  184. );
  185. }
  186. const Wrapper = styled('div')<{isRounded: boolean}>`
  187. position: relative;
  188. background: var(--prism-block-background);
  189. border-radius: ${p => (p.isRounded ? p.theme.borderRadius : '0px')};
  190. pre {
  191. margin: 0;
  192. }
  193. &[data-render-inline='true'] pre {
  194. padding: 0;
  195. }
  196. `;
  197. const Header = styled('div')<{isSolid: boolean}>`
  198. display: flex;
  199. align-items: center;
  200. font-family: ${p => p.theme.text.familyMono};
  201. font-size: ${p => p.theme.codeFontSize};
  202. color: var(--prism-base);
  203. font-weight: ${p => p.theme.fontWeightBold};
  204. z-index: 2;
  205. ${p =>
  206. p.isSolid
  207. ? `
  208. padding: 0 ${space(0.5)};
  209. border-bottom: solid 1px ${p.theme.innerBorder};
  210. `
  211. : `
  212. justify-content: flex-end;
  213. position: absolute;
  214. top: 0;
  215. right: 0;
  216. width: max-content;
  217. height: max-content;
  218. max-height: 100%;
  219. padding: ${space(0.5)};
  220. `}
  221. `;
  222. const FileName = styled('span')`
  223. ${p => p.theme.overflowEllipsis}
  224. padding: ${space(0.5)} ${space(0.5)};
  225. margin: 0;
  226. width: auto;
  227. `;
  228. const TabsWrapper = styled('div')`
  229. padding: 0;
  230. display: flex;
  231. `;
  232. const Tab = styled('button')<{isSelected: boolean}>`
  233. box-sizing: border-box;
  234. display: block;
  235. margin: 0;
  236. border: none;
  237. background: none;
  238. padding: ${space(1)} ${space(1)};
  239. color: var(--prism-comment);
  240. ${p =>
  241. p.isSelected
  242. ? `border-bottom: 3px solid ${p.theme.purple300};
  243. padding-bottom: 5px;
  244. color: var(--prism-base);`
  245. : ''}
  246. `;
  247. const FlexSpacer = styled('div')`
  248. flex-grow: 1;
  249. `;
  250. const CopyButton = styled(Button)<{isAlwaysVisible: boolean}>`
  251. color: var(--prism-comment);
  252. transition: opacity 0.1s ease-out;
  253. opacity: 0;
  254. div:hover > div > &, /* if Wrapper is hovered */
  255. &:focus-visible {
  256. opacity: 1;
  257. }
  258. &:hover {
  259. color: var(--prism-base);
  260. }
  261. ${p => (p.isAlwaysVisible ? 'opacity: 1;' : '')}
  262. `;
  263. const Code = styled('code')<{disableUserSelection?: boolean}>`
  264. user-select: ${p => (p.disableUserSelection ? 'none' : 'auto')};
  265. `;