preview-logs-pane-entry.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import React, { useState, useRef } from 'react'
  2. import PropTypes from 'prop-types'
  3. import classNames from 'classnames'
  4. import { useTranslation } from 'react-i18next'
  5. import { OverlayTrigger, Tooltip } from 'react-bootstrap'
  6. import useExpandCollapse from '../../../shared/hooks/use-expand-collapse'
  7. import useResizeObserver from '../../../shared/hooks/use-resize-observer'
  8. import Icon from '../../../shared/components/icon'
  9. function PreviewLogsPaneEntry({
  10. headerTitle,
  11. headerIcon,
  12. rawContent,
  13. logType,
  14. formattedContent,
  15. extraInfoURL,
  16. level,
  17. sourceLocation,
  18. showSourceLocationLink = true,
  19. showCloseButton = false,
  20. entryAriaLabel = null,
  21. customClass,
  22. onSourceLocationClick,
  23. onClose,
  24. }) {
  25. const logEntryClasses = classNames('log-entry', customClass)
  26. function handleLogEntryLinkClick(e) {
  27. e.preventDefault()
  28. onSourceLocationClick(sourceLocation)
  29. }
  30. return (
  31. <div className={logEntryClasses} aria-label={entryAriaLabel}>
  32. <PreviewLogEntryHeader
  33. level={level}
  34. sourceLocation={sourceLocation}
  35. headerTitle={headerTitle}
  36. headerIcon={headerIcon}
  37. logType={logType}
  38. showSourceLocationLink={showSourceLocationLink}
  39. onSourceLocationClick={handleLogEntryLinkClick}
  40. showCloseButton={showCloseButton}
  41. onClose={onClose}
  42. />
  43. {rawContent || formattedContent ? (
  44. <PreviewLogEntryContent
  45. rawContent={rawContent}
  46. formattedContent={formattedContent}
  47. extraInfoURL={extraInfoURL}
  48. />
  49. ) : null}
  50. </div>
  51. )
  52. }
  53. function PreviewLogEntryHeader({
  54. sourceLocation,
  55. level,
  56. headerTitle,
  57. headerIcon,
  58. logType,
  59. showSourceLocationLink = true,
  60. showCloseButton = false,
  61. onSourceLocationClick,
  62. onClose,
  63. }) {
  64. const { t } = useTranslation()
  65. const logLocationSpanRef = useRef()
  66. const [locationSpanOverflown, setLocationSpanOverflown] = useState(false)
  67. useResizeObserver(
  68. logLocationSpanRef,
  69. locationSpanOverflown,
  70. checkLocationSpanOverflow
  71. )
  72. const file = sourceLocation ? sourceLocation.file : null
  73. const line = sourceLocation ? sourceLocation.line : null
  74. const logEntryHeaderClasses = classNames('log-entry-header', {
  75. 'log-entry-header-error': level === 'error',
  76. 'log-entry-header-warning': level === 'warning',
  77. 'log-entry-header-typesetting': level === 'typesetting',
  78. 'log-entry-header-raw': level === 'raw',
  79. 'log-entry-header-success': level === 'success',
  80. })
  81. const logEntryLocationBtnClasses = classNames('log-entry-header-link', {
  82. 'log-entry-header-link-error': level === 'error',
  83. 'log-entry-header-link-warning': level === 'warning',
  84. 'log-entry-header-link-typesetting': level === 'typesetting',
  85. 'log-entry-header-link-raw': level === 'raw',
  86. 'log-entry-header-link-success': level === 'success',
  87. })
  88. const headerLogLocationTitle = t('navigate_log_source', {
  89. location: file + (line ? `, ${line}` : ''),
  90. })
  91. function checkLocationSpanOverflow(observedElement) {
  92. const spanEl = observedElement.target
  93. const isOverflowing = spanEl.scrollWidth > spanEl.clientWidth
  94. setLocationSpanOverflown(isOverflowing)
  95. }
  96. const locationLinkText =
  97. showSourceLocationLink && file ? `${file}${line ? `, ${line}` : ''}` : null
  98. // Because we want an ellipsis on the left-hand side (e.g. "...longfilename.tex"), the
  99. // `log-entry-header-link-location` class has text laid out from right-to-left using the CSS
  100. // rule `direction: rtl;`.
  101. // This works most of the times, except when the first character of the filename is considered
  102. // a punctuation mark, like `/` (e.g. `/foo/bar/baz.sty`). In this case, because of
  103. // right-to-left writing rules, the punctuation mark is moved to the right-side of the string,
  104. // resulting in `...bar/baz.sty/` instead of `...bar/baz.sty`.
  105. // To avoid this edge-case, we wrap the `logLocationLinkText` in two directional formatting
  106. // characters:
  107. // * \u202A LEFT-TO-RIGHT EMBEDDING Treat the following text as embedded left-to-right.
  108. // * \u202C POP DIRECTIONAL FORMATTING End the scope of the last LRE, RLE, RLO, or LRO.
  109. // This essentially tells the browser that, althought the text is laid out from right-to-left,
  110. // the wrapped portion of text should follow left-to-right writing rules.
  111. const locationLink = locationLinkText ? (
  112. <button
  113. className={logEntryLocationBtnClasses}
  114. type="button"
  115. aria-label={headerLogLocationTitle}
  116. onClick={onSourceLocationClick}
  117. >
  118. <Icon type="chain" />
  119. &nbsp;
  120. <span ref={logLocationSpanRef} className="log-entry-header-link-location">
  121. {`\u202A${locationLinkText}\u202C`}
  122. </span>
  123. </button>
  124. ) : null
  125. const locationTooltip =
  126. locationSpanOverflown && locationLinkText ? (
  127. <Tooltip id={locationLinkText} className="log-location-tooltip">
  128. {locationLinkText}
  129. </Tooltip>
  130. ) : null
  131. var headerTitleText = logType ? `${logType} ${headerTitle}` : headerTitle
  132. return (
  133. <header className={logEntryHeaderClasses}>
  134. {headerIcon ? (
  135. <div className="log-entry-header-icon-container">{headerIcon}</div>
  136. ) : null}
  137. <h3 className="log-entry-header-title">{headerTitleText}</h3>
  138. {locationTooltip ? (
  139. <OverlayTrigger placement="left" overlay={locationTooltip}>
  140. {locationLink}
  141. </OverlayTrigger>
  142. ) : (
  143. locationLink
  144. )}
  145. {showCloseButton ? (
  146. <button
  147. className="btn-inline-link log-entry-header-link"
  148. type="button"
  149. aria-label={t('dismiss_error_popup')}
  150. onClick={onClose}
  151. >
  152. <span aria-hidden="true">&times;</span>
  153. </button>
  154. ) : null}
  155. </header>
  156. )
  157. }
  158. function PreviewLogEntryContent({
  159. rawContent,
  160. formattedContent,
  161. extraInfoURL,
  162. }) {
  163. const {
  164. isExpanded,
  165. needsExpandCollapse,
  166. expandableProps,
  167. toggleProps,
  168. } = useExpandCollapse({
  169. collapsedSize: 150,
  170. })
  171. const buttonContainerClasses = classNames(
  172. 'log-entry-content-button-container',
  173. {
  174. 'log-entry-content-button-container-collapsed': !isExpanded,
  175. }
  176. )
  177. const { t } = useTranslation()
  178. return (
  179. <div className="log-entry-content">
  180. {rawContent ? (
  181. <div className="log-entry-content-raw-container">
  182. <div {...expandableProps}>
  183. <pre className="log-entry-content-raw">{rawContent.trim()}</pre>
  184. </div>
  185. {needsExpandCollapse ? (
  186. <div className={buttonContainerClasses}>
  187. <button
  188. type="button"
  189. className="btn btn-xs btn-default log-entry-btn-expand-collapse"
  190. {...toggleProps}
  191. >
  192. {isExpanded ? (
  193. <>
  194. <Icon type="angle-up" /> {t('collapse')}
  195. </>
  196. ) : (
  197. <>
  198. <Icon type="angle-down" /> {t('expand')}
  199. </>
  200. )}
  201. </button>
  202. </div>
  203. ) : null}
  204. </div>
  205. ) : null}
  206. {formattedContent ? (
  207. <div className="log-entry-formatted-content">{formattedContent}</div>
  208. ) : null}
  209. {extraInfoURL ? (
  210. <div className="log-entry-content-link">
  211. <a href={extraInfoURL} target="_blank">
  212. {t('log_hint_extra_info')}
  213. </a>
  214. </div>
  215. ) : null}
  216. </div>
  217. )
  218. }
  219. PreviewLogEntryHeader.propTypes = {
  220. sourceLocation: PropTypes.shape({
  221. file: PropTypes.string,
  222. // `line should be either a number or null (i.e. not required), but currently sometimes we get
  223. // an empty string (from BibTeX errors), which is why we're using `any` here. We should revert
  224. // to PropTypes.number (not required) once we fix that.
  225. line: PropTypes.any,
  226. column: PropTypes.any,
  227. }),
  228. level: PropTypes.string.isRequired,
  229. headerTitle: PropTypes.string,
  230. headerIcon: PropTypes.element,
  231. logType: PropTypes.string,
  232. showSourceLocationLink: PropTypes.bool,
  233. showCloseButton: PropTypes.bool,
  234. onSourceLocationClick: PropTypes.func,
  235. onClose: PropTypes.func,
  236. }
  237. PreviewLogEntryContent.propTypes = {
  238. rawContent: PropTypes.string,
  239. formattedContent: PropTypes.node,
  240. extraInfoURL: PropTypes.string,
  241. }
  242. PreviewLogsPaneEntry.propTypes = {
  243. sourceLocation: PreviewLogEntryHeader.propTypes.sourceLocation,
  244. headerTitle: PropTypes.string,
  245. headerIcon: PropTypes.element,
  246. rawContent: PropTypes.string,
  247. logType: PropTypes.string,
  248. formattedContent: PropTypes.node,
  249. extraInfoURL: PropTypes.string,
  250. level: PropTypes.oneOf(['error', 'warning', 'typesetting', 'raw', 'success'])
  251. .isRequired,
  252. customClass: PropTypes.string,
  253. showSourceLocationLink: PropTypes.bool,
  254. showCloseButton: PropTypes.bool,
  255. entryAriaLabel: PropTypes.string,
  256. onSourceLocationClick: PropTypes.func,
  257. onClose: PropTypes.func,
  258. }
  259. export default PreviewLogsPaneEntry