log-entry-header.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import classNames from 'classnames'
  2. import {
  3. useState,
  4. useRef,
  5. MouseEventHandler,
  6. ElementType,
  7. useCallback,
  8. KeyboardEventHandler,
  9. useMemo,
  10. } from 'react'
  11. import { useTranslation } from 'react-i18next'
  12. import OLTooltip from '@/shared/components/ol/ol-tooltip'
  13. import {
  14. ErrorLevel,
  15. SourceLocation,
  16. LogEntry as LogEntryData,
  17. } from '@/features/pdf-preview/util/types'
  18. import useResizeObserver from '@/features/preview/hooks/use-resize-observer'
  19. import OLIconButton from '@/shared/components/ol/ol-icon-button'
  20. import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
  21. import MaterialIcon from '@/shared/components/material-icon'
  22. import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
  23. import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
  24. const actionComponents = importOverleafModules(
  25. 'pdfLogEntryHeaderActionComponents'
  26. ) as {
  27. import: { default: ElementType }
  28. path: string
  29. }[]
  30. function LogEntryHeader({
  31. sourceLocation,
  32. level,
  33. headerTitle,
  34. logType,
  35. showSourceLocationLink = true,
  36. onSourceLocationClick,
  37. collapsed,
  38. onToggleCollapsed,
  39. id,
  40. logEntry,
  41. actionButtonsOverride,
  42. openCollapseIconOverride,
  43. }: {
  44. headerTitle: string | React.ReactNode
  45. level: ErrorLevel
  46. logType?: string
  47. sourceLocation?: SourceLocation
  48. showSourceLocationLink?: boolean
  49. onSourceLocationClick?: MouseEventHandler<HTMLButtonElement>
  50. collapsed: boolean
  51. onToggleCollapsed: () => void
  52. id?: string
  53. logEntry?: LogEntryData
  54. actionButtonsOverride?: React.ReactNode
  55. openCollapseIconOverride?: string
  56. }) {
  57. const { t } = useTranslation()
  58. const logLocationSpanRef = useRef<HTMLSpanElement>(null)
  59. const [locationSpanOverflown, setLocationSpanOverflown] = useState(false)
  60. const { findEntityByPath } = useFileTreePathContext()
  61. const { openEntity } = useFileTreeOpenContext()
  62. useResizeObserver(
  63. logLocationSpanRef,
  64. locationSpanOverflown,
  65. checkLocationSpanOverflow
  66. )
  67. const file = sourceLocation ? sourceLocation.file : null
  68. const line = sourceLocation ? sourceLocation.line : null
  69. const logEntryHeaderTextClasses = classNames('log-entry-header-text', {
  70. 'log-entry-header-text-error': level === 'error',
  71. 'log-entry-header-text-warning': level === 'warning',
  72. 'log-entry-header-text-info': level === 'info' || level === 'typesetting',
  73. 'log-entry-header-text-success': level === 'success',
  74. 'log-entry-header-text-raw': level === 'raw',
  75. })
  76. function checkLocationSpanOverflow(observedElement: ResizeObserverEntry) {
  77. const spanEl = observedElement.target
  78. const isOverflowing = spanEl.scrollWidth > spanEl.clientWidth
  79. setLocationSpanOverflown(isOverflowing)
  80. }
  81. const locationText =
  82. showSourceLocationLink && file ? `${file}${line ? `, ${line}` : ''}` : null
  83. // Because we want an ellipsis on the left-hand side (e.g. "...longfilename.tex"), the
  84. // `log-entry-location` class has text laid out from right-to-left using the CSS
  85. // rule `direction: rtl;`.
  86. // This works most of the times, except when the first character of the filename is considered
  87. // a punctuation mark, like `/` (e.g. `/foo/bar/baz.sty`). In this case, because of
  88. // right-to-left writing rules, the punctuation mark is moved to the right-side of the string,
  89. // resulting in `...bar/baz.sty/` instead of `...bar/baz.sty`.
  90. // To avoid this edge-case, we wrap the `logLocationLinkText` in two directional formatting
  91. // characters:
  92. // * \u202A LEFT-TO-RIGHT EMBEDDING Treat the following text as embedded left-to-right.
  93. // * \u202C POP DIRECTIONAL FORMATTING End the scope of the last LRE, RLE, RLO, or LRO.
  94. // This essentially tells the browser that, althought the text is laid out from right-to-left,
  95. // the wrapped portion of text should follow left-to-right writing rules.
  96. const formattedLocationText = locationText ? (
  97. <span ref={logLocationSpanRef} className="log-entry-location">
  98. {`\u202A${locationText}\u202C`}
  99. </span>
  100. ) : null
  101. const headerTitleText = logType ? `${logType} ${headerTitle}` : headerTitle
  102. const fileData = useMemo(
  103. () => file && findEntityByPath(file),
  104. [file, findEntityByPath]
  105. )
  106. const showGoToCodeButton =
  107. showSourceLocationLink &&
  108. !!fileData &&
  109. !(fileData.entity._id === openEntity?.entity._id && !line)
  110. const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
  111. event => {
  112. if (event.key === 'Enter' || event.key === ' ') {
  113. event.preventDefault()
  114. onToggleCollapsed()
  115. }
  116. },
  117. [onToggleCollapsed]
  118. )
  119. return (
  120. <header className="log-entry-header-card">
  121. <div
  122. data-action="expand-collapse"
  123. data-collapsed={collapsed}
  124. className="log-entry-header-button"
  125. onClick={onToggleCollapsed}
  126. role="button"
  127. tabIndex={0}
  128. onKeyDown={handleKeyDown}
  129. aria-label={collapsed ? t('expand') : t('collapse')}
  130. >
  131. <MaterialIcon
  132. className="log-entry-expand-icon"
  133. type={
  134. openCollapseIconOverride ??
  135. (collapsed ? 'chevron_right' : 'expand_more')
  136. }
  137. />
  138. <div className="log-entry-header-content">
  139. <h3 className={logEntryHeaderTextClasses}>{headerTitleText}</h3>
  140. {locationSpanOverflown && formattedLocationText && locationText ? (
  141. <OLTooltip
  142. id={locationText}
  143. description={locationText}
  144. overlayProps={{ placement: 'right' }}
  145. tooltipProps={{ className: 'log-location-tooltip' }}
  146. >
  147. {formattedLocationText}
  148. </OLTooltip>
  149. ) : (
  150. formattedLocationText
  151. )}
  152. </div>
  153. </div>
  154. {actionButtonsOverride ?? (
  155. <div className="log-entry-header-actions">
  156. {showGoToCodeButton && (
  157. <OLTooltip
  158. id={`go-to-location-${locationText}`}
  159. description={t('go_to_code_location')}
  160. overlayProps={{ placement: 'bottom' }}
  161. >
  162. <OLIconButton
  163. onClick={onSourceLocationClick}
  164. variant="ghost"
  165. icon="my_location"
  166. accessibilityLabel={t('go_to_code_location')}
  167. />
  168. </OLTooltip>
  169. )}
  170. {actionComponents.map(({ import: { default: Component }, path }) => (
  171. <Component key={path} logEntry={logEntry} id={id} />
  172. ))}
  173. </div>
  174. )}
  175. </header>
  176. )
  177. }
  178. export default LogEntryHeader