preview-logs-pane-entry.js 8.2 KB

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