codeSnippet.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import {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/loadPrismLanguage';
  9. interface CodeSnippetProps {
  10. children: string;
  11. language: string;
  12. className?: string;
  13. dark?: boolean;
  14. /**
  15. * Makes the text of the element and its sub-elements not selectable.
  16. * Userful when loading parts of a code snippet, and
  17. * we wish to avoid users copying them manually.
  18. */
  19. disableUserSelection?: boolean;
  20. filename?: string;
  21. hideCopyButton?: boolean;
  22. onCopy?: (copiedCode: string) => void;
  23. /**
  24. * Fired when the user selects and copies code snippet manually
  25. */
  26. onSelectAndCopy?: () => void;
  27. }
  28. export function CodeSnippet({
  29. children,
  30. language,
  31. dark,
  32. filename,
  33. hideCopyButton,
  34. onCopy,
  35. className,
  36. onSelectAndCopy,
  37. disableUserSelection,
  38. }: CodeSnippetProps) {
  39. const ref = useRef<HTMLModElement | null>(null);
  40. useEffect(() => {
  41. const element = ref.current;
  42. if (!element) {
  43. return;
  44. }
  45. if (language in Prism.languages) {
  46. Prism.highlightElement(element);
  47. return;
  48. }
  49. loadPrismLanguage(language, {onLoad: () => Prism.highlightElement(element)});
  50. }, [children, language]);
  51. const [tooltipState, setTooltipState] = useState<'copy' | 'copied' | 'error'>('copy');
  52. const handleCopy = () => {
  53. navigator.clipboard
  54. .writeText(children)
  55. .then(() => {
  56. setTooltipState('copied');
  57. })
  58. .catch(() => {
  59. setTooltipState('error');
  60. });
  61. onCopy?.(children);
  62. };
  63. const tooltipTitle =
  64. tooltipState === 'copy'
  65. ? t('Copy')
  66. : tooltipState === 'copied'
  67. ? t('Copied')
  68. : t('Unable to copy');
  69. return (
  70. <Wrapper className={`${dark ? 'prism-dark ' : ''}${className ?? ''}`}>
  71. <Header hasFileName={!!filename}>
  72. {filename && <FileName>{filename}</FileName>}
  73. {!hideCopyButton && (
  74. <CopyButton
  75. type="button"
  76. size="xs"
  77. translucentBorder
  78. borderless={!!filename}
  79. onClick={handleCopy}
  80. title={tooltipTitle}
  81. tooltipProps={{delay: 0, isHoverable: false, position: 'left'}}
  82. onMouseLeave={() => setTooltipState('copy')}
  83. >
  84. <IconCopy size="xs" />
  85. </CopyButton>
  86. )}
  87. </Header>
  88. <pre className={`language-${String(language)}`}>
  89. <Code
  90. ref={ref}
  91. className={`language-${String(language)}`}
  92. onCopy={onSelectAndCopy}
  93. disableUserSelection={disableUserSelection}
  94. >
  95. {children}
  96. </Code>
  97. </pre>
  98. </Wrapper>
  99. );
  100. }
  101. const Wrapper = styled('div')`
  102. position: relative;
  103. background: ${p => p.theme.backgroundSecondary};
  104. border-radius: ${p => p.theme.borderRadius};
  105. pre {
  106. margin: 0;
  107. }
  108. `;
  109. const Header = styled('div')<{hasFileName: boolean}>`
  110. display: flex;
  111. justify-content: space-between;
  112. align-items: center;
  113. font-family: ${p => p.theme.text.familyMono};
  114. font-size: ${p => p.theme.codeFontSize};
  115. color: ${p => p.theme.headingColor};
  116. font-weight: 600;
  117. z-index: 2;
  118. ${p =>
  119. p.hasFileName
  120. ? `
  121. padding: ${space(0.5)} 0;
  122. margin: 0 ${space(0.5)} 0 ${space(2)};
  123. border-bottom: solid 1px ${p.theme.innerBorder};
  124. `
  125. : `
  126. justify-content: flex-end;
  127. position: absolute;
  128. top: 0;
  129. right: 0;
  130. width: max-content;
  131. height: max-content;
  132. max-height: 100%;
  133. padding: ${space(1)};
  134. `}
  135. `;
  136. const FileName = styled('p')`
  137. ${p => p.theme.overflowEllipsis}
  138. margin: 0;
  139. `;
  140. const CopyButton = styled(Button)`
  141. color: ${p => p.theme.subText};
  142. transition: opacity 0.1s ease-out;
  143. opacity: 0;
  144. p + &, /* if preceded by FileName */
  145. div:hover > div > &, /* if Wrapper is hovered */
  146. &.focus-visible {
  147. opacity: 1;
  148. }
  149. `;
  150. const Code = styled('code')<{disableUserSelection?: boolean}>`
  151. user-select: ${p => (p.disableUserSelection ? 'none' : 'auto')};
  152. `;