codeSnippet.tsx 5.8 KB

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