math-preview.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import {
  2. repositionTooltips,
  3. showTooltip,
  4. Tooltip,
  5. ViewPlugin,
  6. } from '@codemirror/view'
  7. import {
  8. Compartment,
  9. EditorState,
  10. Extension,
  11. StateField,
  12. TransactionSpec,
  13. } from '@codemirror/state'
  14. import { loadMathJax } from '../../mathjax/load-mathjax'
  15. import { descendantsOfNodeWithType } from '../utils/tree-query'
  16. import {
  17. MathContainer,
  18. mathAncestorNode,
  19. parseMathContainer,
  20. } from '../utils/tree-operations/math'
  21. import { documentCommands } from '../languages/latex/document-commands'
  22. import { debugConsole } from '@/utils/debugging'
  23. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  24. const REPOSITION_EVENT = 'editor:repositionMathTooltips'
  25. export const mathPreview = (enabled: boolean): Extension => {
  26. if (!isSplitTestEnabled('math-preview')) {
  27. return []
  28. }
  29. return mathPreviewConf.of(enabled ? mathPreviewStateField : [])
  30. }
  31. const mathPreviewConf = new Compartment()
  32. export const setMathPreview = (enabled: boolean): TransactionSpec => ({
  33. effects: mathPreviewConf.reconfigure(enabled ? mathPreviewStateField : []),
  34. })
  35. const mathPreviewStateField = StateField.define<readonly Tooltip[]>({
  36. create: buildTooltips,
  37. update(tooltips, tr) {
  38. if (tr.docChanged || tr.selection) {
  39. tooltips = buildTooltips(tr.state)
  40. }
  41. return tooltips
  42. },
  43. provide: field => [
  44. showTooltip.computeN([field], state => state.field(field)),
  45. ViewPlugin.define(view => {
  46. const listener = () => repositionTooltips(view)
  47. window.addEventListener(REPOSITION_EVENT, listener)
  48. return {
  49. destroy() {
  50. window.removeEventListener(REPOSITION_EVENT, listener)
  51. },
  52. }
  53. }),
  54. ],
  55. })
  56. const renderMath = async (
  57. content: string,
  58. displayMode: boolean,
  59. element: HTMLElement,
  60. definitions: string
  61. ) => {
  62. const MathJax = await loadMathJax()
  63. MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
  64. try {
  65. await MathJax.tex2svgPromise(definitions)
  66. } catch {
  67. // ignore errors thrown during parsing command definitions
  68. }
  69. const math = await MathJax.tex2svgPromise(content, {
  70. ...MathJax.getMetricsFor(element),
  71. display: displayMode,
  72. })
  73. element.textContent = ''
  74. element.append(math)
  75. }
  76. function buildTooltips(state: EditorState): readonly Tooltip[] {
  77. const tooltips: Tooltip[] = []
  78. for (const range of state.selection.ranges) {
  79. if (range.empty) {
  80. const mathContainer = getMathContainer(state, range.from)
  81. const content = buildTooltipContent(state, mathContainer)
  82. if (content && mathContainer) {
  83. const tooltip: Tooltip = {
  84. pos: mathContainer.pos,
  85. above: true,
  86. strictSide: true,
  87. arrow: false,
  88. create() {
  89. const dom = document.createElement('div')
  90. dom.append(content)
  91. dom.className = 'ol-cm-math-tooltip'
  92. return { dom, overlap: true, offset: { x: 0, y: 8 } }
  93. },
  94. }
  95. tooltips.push(tooltip)
  96. }
  97. }
  98. }
  99. return tooltips
  100. }
  101. const getMathContainer = (state: EditorState, pos: number) => {
  102. // if anywhere inside Math, find the whole Math node
  103. const ancestorNode = mathAncestorNode(state, pos)
  104. if (!ancestorNode) return null
  105. const [node] = descendantsOfNodeWithType(ancestorNode, 'Math', 'Math')
  106. if (!node) return null
  107. return parseMathContainer(state, node, ancestorNode)
  108. }
  109. const buildTooltipContent = (
  110. state: EditorState,
  111. math: MathContainer | null
  112. ): HTMLDivElement | null => {
  113. if (!math || !math.content.length) return null
  114. const element = document.createElement('div')
  115. element.style.opacity = '0'
  116. element.style.transition = 'opacity .01s ease-in'
  117. element.textContent = math.content
  118. let definitions = ''
  119. const commandState = state.field(documentCommands, false)
  120. if (commandState?.items) {
  121. for (const command of commandState.items) {
  122. if (command.type === 'definition' && command.raw) {
  123. definitions += `${command.raw}\n`
  124. }
  125. }
  126. }
  127. renderMath(math.content, math.displayMode, element, definitions)
  128. .then(() => {
  129. element.style.opacity = '1'
  130. window.dispatchEvent(new Event(REPOSITION_EVENT))
  131. })
  132. .catch(error => {
  133. debugConsole.error(error)
  134. })
  135. return element
  136. }