log-entry-header.tsx 6.0 KB

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