math-preview.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import {
  2. EditorView,
  3. repositionTooltips,
  4. showTooltip,
  5. Tooltip,
  6. ViewPlugin,
  7. } from '@codemirror/view'
  8. import {
  9. Compartment,
  10. EditorState,
  11. Extension,
  12. StateField,
  13. TransactionSpec,
  14. } from '@codemirror/state'
  15. import { loadMathJax } from '../../mathjax/load-mathjax'
  16. import { descendantsOfNodeWithType } from '../utils/tree-query'
  17. import {
  18. MathContainer,
  19. mathAncestorNode,
  20. parseMathContainer,
  21. } from '../utils/tree-operations/math'
  22. import { documentCommands } from '../languages/latex/document-commands'
  23. import { debugConsole } from '@/utils/debugging'
  24. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  25. import ReactDOM from 'react-dom'
  26. import { SplitTestProvider } from '@/shared/context/split-test-context'
  27. import SplitTestBadge from '@/shared/components/split-test-badge'
  28. import { nodeHasError } from '../utils/tree-operations/common'
  29. const REPOSITION_EVENT = 'editor:repositionMathTooltips'
  30. export const mathPreview = (enabled: boolean): Extension => {
  31. if (!isSplitTestEnabled('math-preview')) {
  32. return []
  33. }
  34. return mathPreviewConf.of(
  35. enabled ? [mathPreviewTheme, mathPreviewStateField] : []
  36. )
  37. }
  38. const mathPreviewConf = new Compartment()
  39. export const setMathPreview = (enabled: boolean): TransactionSpec => ({
  40. effects: mathPreviewConf.reconfigure(enabled ? mathPreviewStateField : []),
  41. })
  42. const mathPreviewStateField = StateField.define<Tooltip | null>({
  43. create: buildTooltip,
  44. update(tooltips, tr) {
  45. if (tr.docChanged || tr.selection) {
  46. tooltips = buildTooltip(tr.state)
  47. }
  48. return tooltips
  49. },
  50. provide: field => [
  51. showTooltip.compute([field], state => state.field(field)),
  52. ViewPlugin.define(view => {
  53. const listener = () => repositionTooltips(view)
  54. window.addEventListener(REPOSITION_EVENT, listener)
  55. return {
  56. destroy() {
  57. window.removeEventListener(REPOSITION_EVENT, listener)
  58. },
  59. }
  60. }),
  61. ],
  62. })
  63. const renderMath = async (
  64. content: string,
  65. displayMode: boolean,
  66. element: HTMLElement,
  67. definitions: string
  68. ) => {
  69. const MathJax = await loadMathJax()
  70. MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
  71. try {
  72. await MathJax.tex2svgPromise(definitions)
  73. } catch {
  74. // ignore errors thrown during parsing command definitions
  75. }
  76. const math = await MathJax.tex2svgPromise(content, {
  77. ...MathJax.getMetricsFor(element),
  78. display: displayMode,
  79. })
  80. element.textContent = ''
  81. element.append(math)
  82. }
  83. function buildTooltip(state: EditorState): Tooltip | null {
  84. const range = state.selection.main
  85. if (!range.empty) {
  86. return null
  87. }
  88. const mathContainer = getMathContainer(state, range.from)
  89. const content = buildTooltipContent(state, mathContainer)
  90. if (!content || !mathContainer) {
  91. return null
  92. }
  93. return {
  94. pos: mathContainer.pos,
  95. above: true,
  96. strictSide: true,
  97. arrow: false,
  98. create() {
  99. const dom = document.createElement('div')
  100. dom.append(content)
  101. const badge = renderSplitTestBadge()
  102. dom.append(badge)
  103. dom.className = 'ol-cm-math-tooltip'
  104. return { dom, overlap: true, offset: { x: 0, y: 8 } }
  105. },
  106. }
  107. }
  108. const getMathContainer = (state: EditorState, pos: number) => {
  109. // if anywhere inside Math, find the whole Math node
  110. const ancestorNode = mathAncestorNode(state, pos)
  111. if (!ancestorNode) return null
  112. const [node] = descendantsOfNodeWithType(ancestorNode, 'Math', 'Math')
  113. if (!node) return null
  114. if (nodeHasError(ancestorNode)) return null
  115. return parseMathContainer(state, node, ancestorNode)
  116. }
  117. const buildTooltipContent = (
  118. state: EditorState,
  119. math: MathContainer | null
  120. ): HTMLDivElement | null => {
  121. if (!math || !math.content.length) return null
  122. const element = document.createElement('div')
  123. element.style.opacity = '0'
  124. element.textContent = math.content
  125. let definitions = ''
  126. const commandState = state.field(documentCommands, false)
  127. if (commandState?.items) {
  128. for (const command of commandState.items) {
  129. if (command.type === 'definition' && command.raw) {
  130. definitions += `${command.raw}\n`
  131. }
  132. }
  133. }
  134. renderMath(math.content, math.displayMode, element, definitions)
  135. .then(() => {
  136. element.style.opacity = '1'
  137. window.dispatchEvent(new Event(REPOSITION_EVENT))
  138. })
  139. .catch(error => {
  140. debugConsole.error(error)
  141. })
  142. return element
  143. }
  144. const renderSplitTestBadge = () => {
  145. const element = document.createElement('span')
  146. ReactDOM.render(
  147. <SplitTestProvider>
  148. <SplitTestBadge
  149. displayOnVariants={['enabled']}
  150. splitTestName="math-preview"
  151. />
  152. </SplitTestProvider>,
  153. element
  154. )
  155. return element
  156. }
  157. /**
  158. * Styles for the preview tooltip
  159. */
  160. const mathPreviewTheme = EditorView.baseTheme({
  161. '&light .ol-cm-math-tooltip': {
  162. boxShadow: '0px 2px 4px 0px #1e253029',
  163. border: '1px solid #e7e9ee !important',
  164. backgroundColor: 'white !important',
  165. },
  166. '&dark .ol-cm-math-tooltip': {
  167. boxShadow: '0px 2px 4px 0px #1e253029',
  168. border: '1px solid #2f3a4c !important',
  169. backgroundColor: '#1b222c !important',
  170. },
  171. })