log-entry-header.tsx 5.4 KB

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