math-preview.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. StateEffect,
  13. StateField,
  14. TransactionSpec,
  15. } from '@codemirror/state'
  16. import { loadMathJax } from '../../mathjax/load-mathjax'
  17. import { descendantsOfNodeWithType } from '../utils/tree-query'
  18. import {
  19. MathContainer,
  20. mathAncestorNode,
  21. parseMathContainer,
  22. } from '../utils/tree-operations/math'
  23. import { documentCommands } from '../languages/latex/document-commands'
  24. import { debugConsole } from '@/utils/debugging'
  25. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  26. import { nodeHasError } from '../utils/tree-operations/common'
  27. import { documentEnvironments } from '../languages/latex/document-environments'
  28. const REPOSITION_EVENT = 'editor:repositionMathTooltips'
  29. const HIDE_TOOLTIP_EVENT = 'editor:hideMathTooltip'
  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. export const hideTooltipEffect = StateEffect.define<null>()
  39. const mathPreviewConf = new Compartment()
  40. export const setMathPreview = (enabled: boolean): TransactionSpec => ({
  41. effects: mathPreviewConf.reconfigure(enabled ? mathPreviewStateField : []),
  42. })
  43. export const mathPreviewStateField = StateField.define<{
  44. tooltip: Tooltip | null
  45. mathContent: HTMLDivElement | null
  46. hide: boolean
  47. }>({
  48. create: buildInitialState,
  49. update(state, tr) {
  50. for (const effect of tr.effects) {
  51. if (effect.is(hideTooltipEffect)) {
  52. return { tooltip: null, hide: true, mathContent: null }
  53. }
  54. }
  55. if (tr.docChanged || tr.selection) {
  56. const mathContainer = getMathContainer(tr.state)
  57. if (mathContainer) {
  58. if (state.hide) {
  59. return { tooltip: null, hide: true, mathContent: null }
  60. } else {
  61. const mathContent = buildTooltipContent(tr.state, mathContainer)
  62. return {
  63. tooltip: buildTooltip(mathContainer, mathContent),
  64. mathContent,
  65. hide: false,
  66. }
  67. }
  68. }
  69. return { tooltip: null, hide: false, mathContent: null }
  70. }
  71. return state
  72. },
  73. provide: field => [
  74. showTooltip.compute([field], state => state.field(field).tooltip),
  75. ViewPlugin.define(view => {
  76. const listener = () => repositionTooltips(view)
  77. const hideTooltip = () => {
  78. view.dispatch({
  79. effects: hideTooltipEffect.of(null),
  80. })
  81. }
  82. window.addEventListener(REPOSITION_EVENT, listener)
  83. window.addEventListener(HIDE_TOOLTIP_EVENT, hideTooltip)
  84. return {
  85. destroy() {
  86. window.removeEventListener(REPOSITION_EVENT, listener)
  87. window.removeEventListener(HIDE_TOOLTIP_EVENT, hideTooltip)
  88. },
  89. }
  90. }),
  91. ],
  92. })
  93. function buildInitialState(state: EditorState) {
  94. const mathContainer = getMathContainer(state)
  95. if (mathContainer) {
  96. const mathContent = buildTooltipContent(state, mathContainer)
  97. return {
  98. tooltip: buildTooltip(mathContainer, mathContent),
  99. mathContent,
  100. hide: false,
  101. }
  102. }
  103. return { tooltip: null, hide: false, mathContent: null }
  104. }
  105. const renderMath = async (
  106. content: string,
  107. displayMode: boolean,
  108. element: HTMLElement,
  109. definitions: string
  110. ) => {
  111. const MathJax = await loadMathJax()
  112. MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
  113. try {
  114. await MathJax.tex2svgPromise(definitions)
  115. } catch {
  116. // ignore errors thrown during parsing command definitions
  117. }
  118. const math = await MathJax.tex2svgPromise(content, {
  119. ...MathJax.getMetricsFor(element),
  120. display: displayMode,
  121. })
  122. element.textContent = ''
  123. element.append(math)
  124. }
  125. function buildTooltip(
  126. mathContainer: MathContainer,
  127. mathContent: HTMLDivElement | null
  128. ): Tooltip | null {
  129. if (!mathContent || !mathContainer) {
  130. return null
  131. }
  132. return {
  133. pos: mathContainer.pos,
  134. above: true,
  135. strictSide: true,
  136. arrow: false,
  137. create() {
  138. const dom = document.createElement('div')
  139. dom.classList.add('ol-cm-math-tooltip-container')
  140. return { dom, overlap: true, offset: { x: 0, y: 8 } }
  141. },
  142. }
  143. }
  144. const getMathContainer = (state: EditorState) => {
  145. const range = state.selection.main
  146. if (!range.empty) {
  147. return null
  148. }
  149. // if anywhere inside Math, find the whole Math node
  150. const ancestorNode = mathAncestorNode(state, range.from)
  151. if (!ancestorNode) return null
  152. const [node] = descendantsOfNodeWithType(ancestorNode, 'Math', 'Math')
  153. if (!node) return null
  154. if (nodeHasError(ancestorNode)) return null
  155. return parseMathContainer(state, node, ancestorNode)
  156. }
  157. const buildTooltipContent = (
  158. state: EditorState,
  159. math: MathContainer | null
  160. ): HTMLDivElement | null => {
  161. if (!math || !math.content.length) return null
  162. const element = document.createElement('div')
  163. element.style.opacity = '0'
  164. element.textContent = math.content
  165. let definitions = ''
  166. const environmentState = state.field(documentEnvironments, false)
  167. if (environmentState?.items) {
  168. for (const environment of environmentState.items) {
  169. if (environment.type === 'definition') {
  170. definitions += `${environment.raw}\n`
  171. }
  172. }
  173. }
  174. const commandState = state.field(documentCommands, false)
  175. if (commandState?.items) {
  176. for (const command of commandState.items) {
  177. if (command.type === 'definition' && command.raw) {
  178. definitions += `${command.raw}\n`
  179. }
  180. }
  181. }
  182. renderMath(math.content, math.displayMode, element, definitions)
  183. .then(() => {
  184. element.style.opacity = '1'
  185. window.dispatchEvent(new Event(REPOSITION_EVENT))
  186. })
  187. .catch(error => {
  188. debugConsole.error(error)
  189. })
  190. return element
  191. }
  192. /**
  193. * Styles for the preview tooltip
  194. */
  195. const mathPreviewTheme = EditorView.baseTheme({
  196. '&light .ol-cm-math-tooltip': {
  197. boxShadow: '0px 2px 4px 0px #1e253029',
  198. border: '1px solid #e7e9ee !important',
  199. backgroundColor: 'white !important',
  200. },
  201. '&dark .ol-cm-math-tooltip': {
  202. boxShadow: '0px 2px 4px 0px #1e253029',
  203. border: '1px solid #2f3a4c !important',
  204. backgroundColor: '#1b222c !important',
  205. },
  206. })