math-preview.tsx 5.4 KB

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