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

Equation Preview

GitOrigin-RevId: 98e71e5d2c1a83d6c9fa685eeee1f4b93a5a3da1
This commit is contained in:
David 2024-07-24 14:38:42 +01:00 committed by Copybot
parent a8a655ff3b
commit c07d2f3fa2
22 changed files with 346 additions and 64 deletions

View file

@ -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,

View file

@ -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()

View file

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

View file

@ -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": "",

View file

@ -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 />

View file

@ -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"
/>
)
}

View file

@ -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,
]
)

View file

@ -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,
}
}

View file

@ -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),

View file

@ -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
}

View file

@ -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)
)
}
}

View file

@ -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(

View file

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

View file

@ -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,
})
}
}

View file

@ -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 }
}

View file

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

View file

@ -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';

View file

@ -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;
}

View file

@ -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.",

View file

@ -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')
})
})

View file

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

View file

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