math-preview.ts 4.6 KB

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