Ver Fonte

Merge pull request #19528 from overleaf/dp-equation-preview

Equation Preview

GitOrigin-RevId: 98e71e5d2c1a83d6c9fa685eeee1f4b93a5a3da1
David há 2 anos atrás
pai
commit
c07d2f3fa2
22 ficheiros alterados com 346 adições e 64 exclusões
  1. 2 0
      services/web/app/src/Features/Project/ProjectController.js
  2. 3 0
      services/web/app/src/Features/User/UserController.js
  3. 1 0
      services/web/app/src/models/User.js
  4. 1 0
      services/web/frontend/extracted-translations.json
  5. 4 0
      services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx
  6. 27 0
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-math-preview.tsx
  7. 7 0
      services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
  8. 10 0
      services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx
  9. 2 0
      services/web/frontend/js/features/source-editor/extensions/index.ts
  10. 159 0
      services/web/frontend/js/features/source-editor/extensions/math-preview.ts
  11. 17 57
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  12. 8 0
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  13. 1 0
      services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts
  14. 10 7
      services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts
  15. 56 0
      services/web/frontend/js/features/source-editor/utils/tree-operations/math.ts
  16. 1 0
      services/web/frontend/js/shared/context/user-settings-context.tsx
  17. 1 0
      services/web/frontend/stylesheets/app/editor.less
  18. 10 0
      services/web/frontend/stylesheets/app/editor/math-preview.less
  19. 1 0
      services/web/locales/en.json
  20. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx
  21. 1 0
      services/web/test/frontend/helpers/editor-providers.jsx
  22. 1 0
      services/web/types/user-settings.ts

+ 2 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -324,6 +324,7 @@ const _ProjectController = {
     const splitTests = [
       !anonymous && 'bib-file-tpr-prompt',
       'compile-log-events',
+      'math-preview',
       'null-test-share-modal',
       'paywall-cta',
       'pdf-caching-cached-url-lookup',
@@ -673,6 +674,7 @@ const _ProjectController = {
           fontFamily: user.ace.fontFamily || 'lucida',
           lineHeight: user.ace.lineHeight || 'normal',
           overallTheme: user.ace.overallTheme,
+          mathPreview: user.ace.mathPreview,
         },
         privilegeLevel,
         anonymous,

+ 3 - 0
services/web/app/src/Features/User/UserController.js

@@ -372,6 +372,9 @@ async function updateUserSettings(req, res, next) {
   if (req.body.lineHeight != null) {
     user.ace.lineHeight = req.body.lineHeight
   }
+  if (req.body.mathPreview != null) {
+    user.ace.mathPreview = req.body.mathPreview
+  }
   await user.save()
 
   const newEmail = req.body.email?.trim().toLowerCase()

+ 1 - 0
services/web/app/src/models/User.js

@@ -86,6 +86,7 @@ const UserSchema = new Schema(
       syntaxValidation: { type: Boolean },
       fontFamily: { type: String },
       lineHeight: { type: String },
+      mathPreview: { type: Boolean, default: true },
     },
     features: {
       collaborators: {

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -396,6 +396,7 @@
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_image_url": "",
   "enter_the_confirmation_code": "",
+  "equation_preview": "",
   "error": "",
   "error_opening_document": "",
   "error_opening_document_detail": "",

+ 4 - 0
services/web/frontend/js/features/editor-left-menu/components/settings-menu.tsx

@@ -16,10 +16,13 @@ import SettingsOverallTheme from './settings/settings-overall-theme'
 import SettingsPdfViewer from './settings/settings-pdf-viewer'
 import SettingsSpellCheckLanguage from './settings/settings-spell-check-language'
 import SettingsSyntaxValidation from './settings/settings-syntax-validation'
+import SettingsMathPreview from './settings/settings-math-preview'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 export default function SettingsMenu() {
   const { t } = useTranslation()
   const anonymous = getMeta('ol-anonymous')
+  const enableMathPreview = useFeatureFlag('math-preview')
 
   if (anonymous) {
     return null
@@ -37,6 +40,7 @@ export default function SettingsMenu() {
         <SettingsAutoComplete />
         <SettingsAutoCloseBrackets />
         <SettingsSyntaxValidation />
+        {enableMathPreview && <SettingsMathPreview />}
         <SettingsEditorTheme />
         <SettingsOverallTheme />
         <SettingsKeybindings />

+ 27 - 0
services/web/frontend/js/features/editor-left-menu/components/settings/settings-math-preview.tsx

@@ -0,0 +1,27 @@
+import { useTranslation } from 'react-i18next'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
+import SettingsMenuSelect from './settings-menu-select'
+
+export default function SettingsMathPreview() {
+  const { t } = useTranslation()
+  const { mathPreview, setMathPreview } = useProjectSettingsContext()
+
+  return (
+    <SettingsMenuSelect
+      onChange={setMathPreview}
+      value={mathPreview}
+      options={[
+        {
+          value: true,
+          label: t('on'),
+        },
+        {
+          value: false,
+          label: t('off'),
+        },
+      ]}
+      label={t('equation_preview')}
+      name="mathPreview"
+    />
+  )
+}

+ 7 - 0
services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx

@@ -26,6 +26,7 @@ type ProjectSettingsSetterContextValue = {
   setFontFamily: (fontFamily: UserSettings['fontFamily']) => void
   setLineHeight: (lineHeight: UserSettings['lineHeight']) => void
   setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void
+  setMathPreview: (mathPreview: UserSettings['mathPreview']) => void
 }
 
 type ProjectSettingsContextValue = Partial<ProjectSettings> &
@@ -69,6 +70,8 @@ export const ProjectSettingsProvider: FC = ({ children }) => {
     setLineHeight,
     pdfViewer,
     setPdfViewer,
+    mathPreview,
+    setMathPreview,
   } = useUserWideSettings()
 
   useProjectWideSettingsSocketListener()
@@ -103,6 +106,8 @@ export const ProjectSettingsProvider: FC = ({ children }) => {
       setLineHeight,
       pdfViewer,
       setPdfViewer,
+      mathPreview,
+      setMathPreview,
     }),
     [
       compiler,
@@ -133,6 +138,8 @@ export const ProjectSettingsProvider: FC = ({ children }) => {
       setLineHeight,
       pdfViewer,
       setPdfViewer,
+      mathPreview,
+      setMathPreview,
     ]
   )
 

+ 10 - 0
services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx

@@ -19,6 +19,7 @@ export default function useUserWideSettings() {
     fontFamily,
     lineHeight,
     pdfViewer,
+    mathPreview,
   } = userSettings
 
   const setOverallTheme = useSetOverallTheme()
@@ -85,6 +86,13 @@ export default function useUserWideSettings() {
     [saveUserSettings]
   )
 
+  const setMathPreview = useCallback(
+    (mathPreview: UserSettings['mathPreview']) => {
+      saveUserSettings('mathPreview', mathPreview)
+    },
+    [saveUserSettings]
+  )
+
   return {
     autoComplete,
     setAutoComplete,
@@ -106,5 +114,7 @@ export default function useUserWideSettings() {
     setLineHeight,
     pdfViewer,
     setPdfViewer,
+    mathPreview,
+    setMathPreview,
   }
 }

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -48,6 +48,7 @@ import { toolbarPanel } from './toolbar/toolbar-panel'
 import { geometryChangeEvent } from './geometry-change-event'
 import { docName } from './doc-name'
 import { fileTreeItemDrop } from './file-tree-item-drop'
+import { mathPreview } from './math-preview'
 
 const moduleExtensions: Array<() => Extension> = importOverleafModules(
   'sourceEditorExtensions'
@@ -125,6 +126,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   emptyLineFiller(),
   trackChanges(options.currentDoc, options.changeManager),
   visual(options.visual),
+  mathPreview(options.settings.mathPreview),
   toolbarPanel(),
   verticalOverflow(),
   highlightActiveLine(options.visual.visual),

+ 159 - 0
services/web/frontend/js/features/source-editor/extensions/math-preview.ts

@@ -0,0 +1,159 @@
+import {
+  repositionTooltips,
+  showTooltip,
+  Tooltip,
+  ViewPlugin,
+} from '@codemirror/view'
+import {
+  Compartment,
+  EditorState,
+  Extension,
+  StateField,
+  TransactionSpec,
+} from '@codemirror/state'
+import { loadMathJax } from '../../mathjax/load-mathjax'
+import { descendantsOfNodeWithType } from '../utils/tree-query'
+import {
+  mathAncestorNode,
+  parseMathContainer,
+} from '../utils/tree-operations/math'
+import { documentCommands } from '../languages/latex/document-commands'
+import { debugConsole } from '@/utils/debugging'
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+
+const REPOSITION_EVENT = 'editor:repositionMathTooltips'
+
+export const mathPreview = (enabled: boolean): Extension => {
+  if (!isSplitTestEnabled('math-preview')) {
+    return []
+  }
+
+  return mathPreviewConf.of(enabled ? mathPreviewStateField : [])
+}
+
+const mathPreviewConf = new Compartment()
+
+export const setMathPreview = (enabled: boolean): TransactionSpec => ({
+  effects: mathPreviewConf.reconfigure(enabled ? mathPreviewStateField : []),
+})
+
+const mathPreviewStateField = StateField.define<readonly Tooltip[]>({
+  create: buildTooltips,
+
+  update(tooltips, tr) {
+    if (tr.docChanged || tr.selection) {
+      tooltips = buildTooltips(tr.state)
+    }
+
+    return tooltips
+  },
+
+  provide: field => [
+    showTooltip.computeN([field], state => state.field(field)),
+
+    ViewPlugin.define(view => {
+      const listener = () => repositionTooltips(view)
+      window.addEventListener(REPOSITION_EVENT, listener)
+      return {
+        destroy() {
+          window.removeEventListener(REPOSITION_EVENT, listener)
+        },
+      }
+    }),
+  ],
+})
+
+const renderMath = async (
+  content: string,
+  displayMode: boolean,
+  element: HTMLElement,
+  definitions: string
+) => {
+  const MathJax = await loadMathJax()
+
+  MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
+
+  try {
+    await MathJax.tex2svgPromise(definitions)
+  } catch {
+    // ignore errors thrown during parsing command definitions
+  }
+
+  const math = await MathJax.tex2svgPromise(content, {
+    ...MathJax.getMetricsFor(element),
+    display: displayMode,
+  })
+  element.textContent = ''
+  element.append(math)
+}
+
+function buildTooltips(state: EditorState): readonly Tooltip[] {
+  const tooltips: Tooltip[] = []
+
+  for (const range of state.selection.ranges) {
+    if (range.empty) {
+      const pos = range.from
+      const content = buildTooltipContent(state, pos)
+      if (content) {
+        const tooltip: Tooltip = {
+          pos,
+          above: true,
+          arrow: false,
+          create() {
+            const dom = document.createElement('div')
+            dom.append(content)
+            dom.className = 'ol-cm-math-tooltip'
+
+            return { dom, overlap: true, offset: { x: 0, y: 8 } }
+          },
+        }
+
+        tooltips.push(tooltip)
+      }
+    }
+  }
+
+  return tooltips
+}
+
+const buildTooltipContent = (
+  state: EditorState,
+  pos: number
+): HTMLDivElement | null => {
+  // if anywhere inside Math, render the whole Math content
+  const ancestorNode = mathAncestorNode(state, pos)
+  if (!ancestorNode) return null
+
+  const [node] = descendantsOfNodeWithType(ancestorNode, 'Math', 'Math')
+  if (!node) return null
+
+  const math = parseMathContainer(state, node, ancestorNode)
+  if (!math || !math.content.length) return null
+
+  const element = document.createElement('div')
+  element.style.opacity = '0'
+  element.style.transition = 'opacity .01s ease-in'
+  element.textContent = math.content
+
+  let definitions = ''
+  const commandState = state.field(documentCommands, false)
+
+  if (commandState?.items) {
+    for (const command of commandState.items) {
+      if (command.type === 'definition' && command.raw) {
+        definitions += `${command.raw}\n`
+      }
+    }
+  }
+
+  renderMath(math.content, math.displayMode, element, definitions)
+    .then(() => {
+      element.style.opacity = '1'
+      window.dispatchEvent(new Event(REPOSITION_EVENT))
+    })
+    .catch(error => {
+      debugConsole.error(error)
+    })
+
+  return element
+}

+ 17 - 57
services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts

@@ -82,6 +82,10 @@ import {
   createSpaceCommand,
   hasSpaceSubstitution,
 } from '@/features/source-editor/extensions/visual/visual-widgets/space'
+import {
+  mathAncestorNode,
+  parseMathContainer,
+} from '../../utils/tree-operations/math'
 
 type Options = {
   previewByPath: (path: string) => PreviewPath | null
@@ -760,13 +764,7 @@ export const atomicDecorations = (options: Options) => {
           return false // no markup in input content
         } else if (nodeRef.type.is('Math')) {
           // math equations
-          let passToMathJax = true
-
-          const ancestorNode =
-            ancestorNodeOfType(state, nodeRef.from, '$MathContainer') ||
-            ancestorNodeOfType(state, nodeRef.from, 'EquationEnvironment') ||
-            // NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
-            ancestorNodeOfType(state, nodeRef.from, 'EquationArrayEnvironment')
+          const ancestorNode = mathAncestorNode(state, nodeRef.from)
 
           if (
             ancestorNode &&
@@ -774,57 +772,19 @@ export const atomicDecorations = (options: Options) => {
               ? shouldDecorateFromLineEdges(state, ancestorNode)
               : shouldDecorate(state, ancestorNode))
           ) {
-            // the content of the Math element, without braces
-            const innerContent = state.doc
-              .sliceString(nodeRef.from, nodeRef.to)
-              .trim()
+            const math = parseMathContainer(state, nodeRef, ancestorNode)
 
-            // only replace when there's content inside the braces
-            if (innerContent.length) {
-              let content = innerContent
-              let displayMode = false
-
-              if (ancestorNode.type.is('$Environment')) {
-                const environmentName = getEnvironmentName(ancestorNode, state)
-                if (environmentName) {
-                  // use the outer content of environments that MathJax supports
-                  // https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments
-                  if (environmentName === 'tikzcd') {
-                    passToMathJax = false
-                  }
-                  if (
-                    environmentName !== 'math' &&
-                    environmentName !== 'displaymath'
-                  ) {
-                    content = state.doc
-                      .sliceString(ancestorNode.from, ancestorNode.to)
-                      .trim()
-                  }
-
-                  if (environmentName !== 'math') {
-                    displayMode = true
-                  }
-                }
-              } else {
-                if (
-                  ancestorNode.type.is('BracketMath') ||
-                  Boolean(ancestorNode.getChild('DisplayMath'))
-                ) {
-                  displayMode = true
-                }
-              }
-              if (passToMathJax) {
-                decorations.push(
-                  Decoration.replace({
-                    widget: new MathWidget(
-                      content,
-                      displayMode,
-                      commandDefinitions
-                    ),
-                    block: displayMode,
-                  }).range(ancestorNode.from, ancestorNode.to)
-                )
-              }
+            if (math && math.passToMathJax) {
+              decorations.push(
+                Decoration.replace({
+                  widget: new MathWidget(
+                    math.content,
+                    math.displayMode,
+                    commandDefinitions
+                  ),
+                  block: math.displayMode,
+                }).range(ancestorNode.from, ancestorNode.to)
+              )
             }
           }
 

+ 8 - 0
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -61,6 +61,7 @@ import { debugConsole } from '@/utils/debugging'
 import { useMetadataContext } from '@/features/ide-react/context/metadata-context'
 import { useUserContext } from '@/shared/context/user-context'
 import { useReferencesContext } from '@/features/ide-react/context/references-context'
+import { setMathPreview } from '@/features/source-editor/extensions/math-preview'
 
 function useCodeMirrorScope(view: EditorView) {
   const { fileTreeData } = useFileTreeData()
@@ -96,6 +97,7 @@ function useCodeMirrorScope(view: EditorView) {
     autoPairDelimiters,
     mode,
     syntaxValidation,
+    mathPreview,
   } = userSettings
 
   const [cursorHighlights] = useScopeValue<Record<string, Highlight[]>>(
@@ -153,6 +155,7 @@ function useCodeMirrorScope(view: EditorView) {
     autoPairDelimiters,
     mode,
     syntaxValidation,
+    mathPreview,
   })
 
   const currentDocRef = useRef({
@@ -385,6 +388,11 @@ function useCodeMirrorScope(view: EditorView) {
     view.dispatch(setSyntaxValidation(syntaxValidation))
   }, [view, syntaxValidation])
 
+  useEffect(() => {
+    settingsRef.current.mathPreview = mathPreview
+    view.dispatch(setMathPreview(mathPreview))
+  }, [view, mathPreview])
+
   const emitSyncToPdf = useScopeEventEmitter('cursor:editor:syncToPdf')
 
   const handleGoToLine = useCallback(

+ 1 - 0
services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts

@@ -46,6 +46,7 @@ const countCommandUsage = (context: CompletionContext) => {
   >()
 
   const commandListProjection = context.state.field(documentCommands)
+
   if (!commandListProjection.items) {
     return result
   }

+ 10 - 7
services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts

@@ -10,6 +10,8 @@ export class Command extends ProjectionItem {
   readonly title: string = ''
   readonly optionalArgCount: number = 0
   readonly requiredArgCount: number = 0
+  readonly type: 'usage' | 'definition' = 'usage'
+  readonly raw: string | undefined = undefined
 }
 
 /**
@@ -62,16 +64,16 @@ export const enterNode = (
       argCountNumber--
     }
 
-    const thisCommand: Readonly<Command> = {
+    items.push({
       line: state.doc.lineAt(node.from).number,
       title: commandNameText,
       from: node.from,
       to: node.to,
       optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0,
       requiredArgCount: argCountNumber,
-    }
-
-    items.push(thisCommand)
+      type: 'definition',
+      raw: state.sliceDoc(node.from, node.to),
+    })
   } else if (
     node.type.is('UnknownCommand') ||
     node.type.is('KnownCommand') ||
@@ -112,7 +114,7 @@ export const enterNode = (
       commandNode.getChildren('$Argument')
     const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to)
 
-    const thisCommand = {
+    items.push({
       line: state.doc.lineAt(commandNode.from).number,
       title: text,
       from: commandNode.from,
@@ -120,7 +122,8 @@ export const enterNode = (
       optionalArgCount: optionalArguments.length,
       requiredArgCount:
         commandArgumentsIncludingOptional.length - optionalArguments.length,
-    }
-    items.push(thisCommand)
+      type: 'usage',
+      raw: undefined,
+    })
   }
 }

+ 56 - 0
services/web/frontend/js/features/source-editor/utils/tree-operations/math.ts

@@ -0,0 +1,56 @@
+import { getEnvironmentName } from './environments'
+import { EditorState } from '@codemirror/state'
+import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
+import { ancestorNodeOfType } from './ancestors'
+
+export const mathAncestorNode = (state: EditorState, pos: number) =>
+  ancestorNodeOfType(state, pos, '$MathContainer') ||
+  ancestorNodeOfType(state, pos, 'EquationEnvironment') ||
+  // NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
+  ancestorNodeOfType(state, pos, 'EquationArrayEnvironment')
+
+export const parseMathContainer = (
+  state: EditorState,
+  nodeRef: SyntaxNodeRef,
+  ancestorNode: SyntaxNode
+) => {
+  // the content of the Math element, without braces
+  const innerContent = state.doc.sliceString(nodeRef.from, nodeRef.to).trim()
+
+  if (!innerContent.length) {
+    return null
+  }
+
+  let content = innerContent
+  let displayMode = false
+  let passToMathJax = true
+
+  if (ancestorNode.type.is('$Environment')) {
+    const environmentName = getEnvironmentName(ancestorNode, state)
+    if (environmentName) {
+      // use the outer content of environments that MathJax supports
+      // https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments
+      if (environmentName === 'tikzcd') {
+        passToMathJax = false
+      }
+      if (environmentName !== 'math' && environmentName !== 'displaymath') {
+        content = state.doc
+          .sliceString(ancestorNode.from, ancestorNode.to)
+          .trim()
+      }
+
+      if (environmentName !== 'math') {
+        displayMode = true
+      }
+    }
+  } else {
+    if (
+      ancestorNode.type.is('BracketMath') ||
+      Boolean(ancestorNode.getChild('DisplayMath'))
+    ) {
+      displayMode = true
+    }
+  }
+
+  return { content, displayMode, passToMathJax }
+}

+ 1 - 0
services/web/frontend/js/shared/context/user-settings-context.tsx

@@ -23,6 +23,7 @@ const defaultSettings: UserSettings = {
   fontSize: 12,
   fontFamily: 'monaco',
   lineHeight: 'normal',
+  mathPreview: true,
 }
 
 type UserSettingsContextValue = {

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

@@ -11,6 +11,7 @@
 @import './editor/search.less';
 @import './editor/publish-template.less';
 @import './editor/online-users.less';
+@import './editor/math-preview.less';
 @import './editor/hotkeys.less';
 @import './editor/review-panel.less';
 @import './editor/publish-modal.less';

+ 10 - 0
services/web/frontend/stylesheets/app/editor/math-preview.less

@@ -0,0 +1,10 @@
+.ol-cm-math-tooltip {
+  box-shadow: 0px 2px 4px 0px #1e253029;
+  border: 1px solid #e7e9ee !important;
+  border-radius: 4px;
+  background-color: white !important;
+  max-height: 400px;
+  max-width: 800px;
+  overflow: auto;
+  padding: 8px;
+}

+ 1 - 0
services/web/locales/en.json

@@ -565,6 +565,7 @@
   "enter_your_email_address": "Enter your email address",
   "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
   "enter_your_new_password": "Enter your new password",
+  "equation_preview": "Equation preview",
   "error": "Error",
   "error_opening_document": "Error opening document",
   "error_opening_document_detail": "Sorry, something went wrong opening this document. Please try again.",

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-math-preview.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsMathPreview from '@/features/editor-left-menu/components/settings/settings-math-preview'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsMathPreview />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsMathPreview />)
+
+    const select = screen.getByLabelText('Equation preview')
+
+    const optionOn = within(select).getByText('On')
+    expect(optionOn.getAttribute('value')).to.equal('true')
+
+    const optionOff = within(select).getByText('Off')
+    expect(optionOff.getAttribute('value')).to.equal('false')
+  })
+})

+ 1 - 0
services/web/test/frontend/helpers/editor-providers.jsx

@@ -54,6 +54,7 @@ const defaultUserSettings = {
   autoPairDelimiters: true,
   trackChanges: true,
   syntaxValidation: false,
+  mathPreview: true,
 }
 
 export function EditorProviders({

+ 1 - 0
services/web/types/user-settings.ts

@@ -16,4 +16,5 @@ export type UserSettings = {
   fontSize: number
   fontFamily: FontFamily
   lineHeight: LineHeight
+  mathPreview: boolean
 }