preview-logs-pane-entry.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 [showLocationTooltip, setShowLocationTooltip] = useState(false)
  67. useResizeObserver(
  68. logLocationSpanRef,
  69. showLocationTooltip,
  70. setTooltipForLogLocationLinkIfNeeded
  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 setTooltipForLogLocationLinkIfNeeded(observedElement) {
  92. const spanEl = observedElement.target
  93. const shouldShowTooltip = spanEl.scrollWidth > spanEl.clientWidth
  94. setShowLocationTooltip(shouldShowTooltip)
  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 = showLocationTooltip ? (
  126. <Tooltip id={locationLinkText} className="log-location-tooltip">
  127. {locationLinkText}
  128. </Tooltip>
  129. ) : null
  130. var headerTitleText = logType ? `${logType} ${headerTitle}` : headerTitle
  131. return (
  132. <header className={logEntryHeaderClasses}>
  133. {headerIcon ? (
  134. <div className="log-entry-header-icon-container">{headerIcon}</div>
  135. ) : null}
  136. <h3 className="log-entry-header-title">{headerTitleText}</h3>
  137. {showLocationTooltip ? (
  138. <OverlayTrigger placement="left" overlay={locationTooltip}>
  139. {locationLink}
  140. </OverlayTrigger>
  141. ) : (
  142. locationLink
  143. )}
  144. {showCloseButton ? (
  145. <button
  146. className="btn-inline-link log-entry-header-link"
  147. type="button"
  148. aria-label={t('dismiss_error_popup')}
  149. onClick={onClose}
  150. >
  151. <span aria-hidden="true">&times;</span>
  152. </button>
  153. ) : null}
  154. </header>
  155. )
  156. }
  157. function PreviewLogEntryContent({
  158. rawContent,
  159. formattedContent,
  160. extraInfoURL
  161. }) {
  162. const {
  163. isExpanded,
  164. needsExpandCollapse,
  165. expandableProps,
  166. toggleProps
  167. } = useExpandCollapse({
  168. collapsedSize: 150
  169. })
  170. const buttonContainerClasses = classNames(
  171. 'log-entry-content-button-container',
  172. {
  173. 'log-entry-content-button-container-collapsed': !isExpanded
  174. }
  175. )
  176. const { t } = useTranslation()
  177. return (
  178. <div className="log-entry-content">
  179. {rawContent ? (
  180. <div className="log-entry-content-raw-container">
  181. <div {...expandableProps}>
  182. <pre className="log-entry-content-raw">{rawContent.trim()}</pre>
  183. </div>
  184. {needsExpandCollapse ? (
  185. <div className={buttonContainerClasses}>
  186. <button
  187. type="button"
  188. className="btn btn-xs btn-default log-entry-btn-expand-collapse"
  189. {...toggleProps}
  190. >
  191. {isExpanded ? (
  192. <>
  193. <Icon type="angle-up" /> {t('collapse')}
  194. </>
  195. ) : (
  196. <>
  197. <Icon type="angle-down" /> {t('expand')}
  198. </>
  199. )}
  200. </button>
  201. </div>
  202. ) : null}
  203. </div>
  204. ) : null}
  205. {formattedContent ? (
  206. <div className="log-entry-formatted-content">{formattedContent}</div>
  207. ) : null}
  208. {extraInfoURL ? (
  209. <div className="log-entry-content-link">
  210. <a href={extraInfoURL} target="_blank">
  211. {t('log_hint_extra_info')}
  212. </a>
  213. </div>
  214. ) : null}
  215. </div>
  216. )
  217. }
  218. PreviewLogEntryHeader.propTypes = {
  219. sourceLocation: PropTypes.shape({
  220. file: PropTypes.string,
  221. // `line should be either a number or null (i.e. not required), but currently sometimes we get
  222. // an empty string (from BibTeX errors), which is why we're using `any` here. We should revert
  223. // to PropTypes.number (not required) once we fix that.
  224. line: PropTypes.any,
  225. column: PropTypes.any
  226. }),
  227. level: PropTypes.string.isRequired,
  228. headerTitle: PropTypes.string,
  229. headerIcon: PropTypes.element,
  230. logType: PropTypes.string,
  231. showSourceLocationLink: PropTypes.bool,
  232. showCloseButton: PropTypes.bool,
  233. onSourceLocationClick: PropTypes.func,
  234. onClose: PropTypes.func
  235. }
  236. PreviewLogEntryContent.propTypes = {
  237. rawContent: PropTypes.string,
  238. formattedContent: PropTypes.node,
  239. extraInfoURL: PropTypes.string
  240. }
  241. PreviewLogsPaneEntry.propTypes = {
  242. sourceLocation: PreviewLogEntryHeader.propTypes.sourceLocation,
  243. headerTitle: PropTypes.string,
  244. headerIcon: PropTypes.element,
  245. rawContent: PropTypes.string,
  246. logType: PropTypes.string,
  247. formattedContent: PropTypes.node,
  248. extraInfoURL: PropTypes.string,
  249. level: PropTypes.oneOf(['error', 'warning', 'typesetting', 'raw', 'success'])
  250. .isRequired,
  251. customClass: PropTypes.string,
  252. showSourceLocationLink: PropTypes.bool,
  253. showCloseButton: PropTypes.bool,
  254. entryAriaLabel: PropTypes.string,
  255. onSourceLocationClick: PropTypes.func,
  256. onClose: PropTypes.func
  257. }
  258. export default PreviewLogsPaneEntry