diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts index 884ceba6dc..7911fc21cd 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts @@ -50,6 +50,8 @@ import { CloseBrace, OpenBrace } from '../../lezer-latex/latex.terms.mjs' import { FootnoteWidget } from './visual-widgets/footnote' import { getListItems } from '../toolbar/lists' import { TildeWidget } from './visual-widgets/tilde' +import { BeginTheoremWidget } from './visual-widgets/begin-theorem' +import { parseTheoremArguments } from '../../utils/tree-operations/theorems' type Options = { fileTreeManager: { @@ -136,6 +138,13 @@ export const atomicDecorations = (options: Options) => { let listDepth = 0 + const theoremEnvironments = new Map([ + ['theorem', 'Theorem'], + ['corollary', 'Corollary'], + ['lemma', 'lemma'], + ['proof', 'Proof'], + ]) + const preamble: { from: number to: number @@ -322,7 +331,7 @@ export const atomicDecorations = (options: Options) => { } } else if (nodeRef.type.is('BeginEnv')) { // the beginning of an environment, with an environment name argument - const envName = getEnvironmentName(nodeRef.node, state) + const envName = getUnstarredEnvironmentName(nodeRef.node, state) if (envName) { switch (envName) { @@ -398,7 +407,28 @@ export const atomicDecorations = (options: Options) => { } break default: - // do nothing + { + const theoremName = theoremEnvironments.get(envName) + + if (theoremName && shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('OptionalArgument') + ?.getChild('ShortOptionalArg') + + decorations.push( + Decoration.replace({ + widget: new BeginTheoremWidget( + envName, + theoremName, + argumentNode + ), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + + // do nothing + } break } } @@ -447,6 +477,16 @@ export const atomicDecorations = (options: Options) => { } break default: + if (theoremEnvironments.has(envName)) { + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new EndWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + } // do nothing break } @@ -818,6 +858,12 @@ export const atomicDecorations = (options: Options) => { ) return false } + } else if (nodeRef.type.is('NewTheoremCommand')) { + const result = parseTheoremArguments(state, nodeRef.node) + if (result) { + const { name, label } = result + theoremEnvironments.set(name, label) + } } else if (nodeRef.type.is('UnknownCommand')) { // a command that's not defined separately by the grammar const commandNode = nodeRef.node diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts index 135ead530b..9d5173472d 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts @@ -8,6 +8,7 @@ import { EditorState, Range } from '@codemirror/state' import { syntaxTree } from '@codemirror/language' import { getUnstarredEnvironmentName } from '../../utils/tree-operations/environments' import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure' +import { parseTheoremStyles } from '../../utils/tree-operations/theorems' import { Tree } from '@lezer/common' /** @@ -22,6 +23,8 @@ export const markDecorations = ViewPlugin.define( ): DecorationSet => { const decorations: Range[] = [] + const theoremStyles = parseTheoremStyles(state, tree) + for (const { from, to } of view.visibleRanges) { tree?.iterate({ from, @@ -108,46 +111,96 @@ export const markDecorations = ViewPlugin.define( state ) - switch (environmentName) { - case 'abstract': - case 'figure': - case 'table': - case 'verbatim': - case 'lstlisting': - { - const centered = Boolean( - centeringNodeForEnvironment(nodeRef) - ) + if (environmentName) { + switch (environmentName) { + case 'abstract': + case 'figure': + case 'table': + case 'verbatim': + case 'lstlisting': + { + const centered = Boolean( + centeringNodeForEnvironment(nodeRef) + ) - const lines = { - start: state.doc.lineAt(nodeRef.from), - end: state.doc.lineAt(nodeRef.to), - } - - for ( - let lineNumber = lines.start.number; - lineNumber <= lines.end.number; - lineNumber++ - ) { - const line = state.doc.line(lineNumber) - - const classNames = [ - `ol-cm-environment-${environmentName}`, - 'ol-cm-environment-line', - ] - - if (centered) { - classNames.push('ol-cm-environment-centered') + const lines = { + start: state.doc.lineAt(nodeRef.from), + end: state.doc.lineAt(nodeRef.to), } - decorations.push( - Decoration.line({ - class: classNames.join(' '), - }).range(line.from) - ) + for ( + let lineNumber = lines.start.number; + lineNumber <= lines.end.number; + lineNumber++ + ) { + const line = state.doc.line(lineNumber) + + const classNames = [ + `ol-cm-environment-${environmentName}`, + 'ol-cm-environment-line', + ] + + if (centered) { + classNames.push('ol-cm-environment-centered') + } + + decorations.push( + Decoration.line({ + class: classNames.join(' '), + }).range(line.from) + ) + } } - } - break + break + + default: + if (theoremStyles.has(environmentName)) { + const theoremStyle = theoremStyles.get(environmentName) + + if (theoremStyle) { + const lines = { + start: state.doc.lineAt(nodeRef.from), + end: state.doc.lineAt(nodeRef.to), + } + + decorations.push( + Decoration.line({ + class: [ + `ol-cm-environment-theorem-${theoremStyle}`, + 'ol-cm-environment-first-line', + ].join(' '), + }).range(lines.start.from) + ) + + for ( + let lineNumber = lines.start.number + 1; + lineNumber <= lines.end.number - 1; + lineNumber++ + ) { + const line = state.doc.line(lineNumber) + + decorations.push( + Decoration.line({ + class: [ + `ol-cm-environment-theorem-${theoremStyle}`, + 'ol-cm-environment-line', + ].join(' '), + }).range(line.from) + ) + } + + decorations.push( + Decoration.line({ + class: [ + `ol-cm-environment-theorem-${theoremStyle}`, + 'ol-cm-environment-last-line', + ].join(' '), + }).range(lines.start.from) + ) + } + } + break + } } } }, diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index f34258c4f9..21266832a2 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts @@ -113,7 +113,7 @@ export const visualTheme = EditorView.theme({ }, '.ol-cm-end': { fontFamily: 'var(--source-font-family)', - padding: '0.5em 0 1.5em', + paddingBottom: '1.5em', minHeight: '1em', textAlign: 'center', justifyContent: 'center', @@ -144,6 +144,12 @@ export const visualTheme = EditorView.theme({ { boxShadow: '0 2px 5px -3px rgb(125, 125, 125, 0.5)', }, + '.ol-cm-environment-theorem-plain': { + fontStyle: 'italic', + }, + '.ol-cm-begin-proof > .ol-cm-environment-name': { + fontStyle: 'italic', + }, '.ol-cm-environment-padding': { flex: 1, height: '1px', @@ -152,13 +158,20 @@ export const visualTheme = EditorView.theme({ '.ol-cm-environment-name': { padding: '0 1em', }, - '.ol-cm-environment-name-abstract': { + '.ol-cm-begin-abstract > .ol-cm-environment-name': { fontFamily: 'var(--visual-font-family)', fontSize: '1.2em', fontWeight: 550, + textTransform: 'capitalize', }, - '.ol-cm-environment-name-abstract:first-letter': { - textTransform: 'uppercase', + '.ol-cm-begin-theorem > .ol-cm-environment-name': { + fontFamily: 'var(--visual-font-family)', + fontWeight: 550, + padding: '0 6px', + textTransform: 'capitalize', + }, + '.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': { + flex: 0, }, '.ol-cm-item': { paddingInlineStart: 'calc(var(--list-depth) * 2ch)', diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin-theorem.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin-theorem.ts new file mode 100644 index 0000000000..7fd3efa80d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin-theorem.ts @@ -0,0 +1,55 @@ +import { BeginWidget } from './begin' +import { EditorView } from '@codemirror/view' +import { SyntaxNode } from '@lezer/common' +import { typesetNodeIntoElement } from '../utils/typeset-content' +import { loadMathJax } from '../../../../mathjax/load-mathjax' + +export class BeginTheoremWidget extends BeginWidget { + constructor( + public environment: string, + public name: string, + public argumentNode?: SyntaxNode | null + ) { + super(environment) + } + + toDOM(view: EditorView) { + const element = super.toDOM(view) + element.classList.add('ol-cm-begin-theorem') + return element + } + + updateDOM(element: HTMLDivElement, view: EditorView) { + super.updateDOM(element, view) + element.classList.add('ol-cm-begin-theorem') + return true + } + + eq(widget: BeginTheoremWidget) { + return ( + super.eq(widget) && + widget.name === this.name && + widget.argumentNode === this.argumentNode + ) + } + + buildName(nameElement: HTMLSpanElement, view: EditorView) { + nameElement.textContent = this.name + if (this.argumentNode) { + const suffixElement = document.createElement('span') + typesetNodeIntoElement(this.argumentNode, suffixElement, view.state) + nameElement.append(' (', suffixElement, ')') + + loadMathJax() + .then(async MathJax => { + if (!this.destroyed) { + await MathJax.typesetPromise([nameElement]) + view.requestMeasure() + } + }) + .catch(() => { + nameElement.classList.add('ol-cm-error') + }) + } + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts index 3f76c8e3db..9059940277 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts @@ -2,28 +2,16 @@ import { EditorView, WidgetType } from '@codemirror/view' import { placeSelectionInsideBlock } from '../selection' export class BeginWidget extends WidgetType { + destroyed = false + constructor(public environment: string) { super() } toDOM(view: EditorView) { + this.destroyed = false const element = document.createElement('div') - element.classList.add('ol-cm-begin') - element.classList.add(`ol-cm-begin-${this.environment}`) - - const leftPadding = document.createElement('span') - leftPadding.classList.add('ol-cm-environment-padding') - element.appendChild(leftPadding) - - const name = document.createElement('span') - name.textContent = this.environment - name.classList.add('ol-cm-environment-name') - name.classList.add(`ol-cm-environment-name-${this.environment}`) - element.appendChild(name) - - const rightPadding = document.createElement('span') - rightPadding.classList.add('ol-cm-environment-padding') - element.appendChild(rightPadding) + this.buildElement(element, view) element.addEventListener('mouseup', event => { event.preventDefault() @@ -37,13 +25,46 @@ export class BeginWidget extends WidgetType { return widget.environment === this.environment } - updateDOM(element: HTMLDivElement) { - element.querySelector('.ol-cm-environment-name')!.textContent = - this.environment + updateDOM(element: HTMLDivElement, view: EditorView) { + this.destroyed = false + element.textContent = '' + element.className = '' + this.buildElement(element, view) return true } + destroy() { + this.destroyed = true + } + ignoreEvent(event: Event): boolean { return event.type !== 'mouseup' } + + buildName(name: HTMLSpanElement, view: EditorView) { + name.textContent = this.environment + } + + buildElement(element: HTMLDivElement, view: EditorView) { + element.classList.add('ol-cm-begin', `ol-cm-begin-${this.environment}`) + + const startPadding = document.createElement('span') + startPadding.classList.add( + 'ol-cm-environment-padding', + 'ol-cm-environment-start-padding' + ) + element.appendChild(startPadding) + + const name = document.createElement('span') + name.classList.add('ol-cm-environment-name') + this.buildName(name, view) + element.appendChild(name) + + const endPadding = document.createElement('span') + endPadding.classList.add( + 'ol-cm-environment-padding', + 'ol-cm-environment-end-padding' + ) + element.appendChild(endPadding) + } } diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar index e9515d7934..a98e67eeac 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar +++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar @@ -81,6 +81,8 @@ InputCtrlSeq, IncludeCtrlSeq, ItemCtrlSeq, + NewTheoremCtrlSeq, + TheoremStyleCtrlSeq, CenteringCtrlSeq, BibliographyCtrlSeq, BibliographyStyleCtrlSeq, @@ -240,6 +242,12 @@ KnownCommand { HrefCommand { HrefCtrlSeq optionalWhitespace? UrlArgument ShortTextArgument } | + NewTheoremCommand { + NewTheoremCtrlSeq "*"? optionalWhitespace? ShortTextArgument ((OptionalArgument? TextArgument) | (TextArgument OptionalArgument)) + } | + TheoremStyleCommand { + TheoremStyleCtrlSeq optionalWhitespace? ShortTextArgument + } | VerbCommand { VerbCtrlSeq VerbContent } | diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs index ef72e98ee2..1bea584bd3 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs @@ -61,6 +61,8 @@ import { InputCtrlSeq, IncludeCtrlSeq, ItemCtrlSeq, + NewTheoremCtrlSeq, + TheoremStyleCtrlSeq, BibliographyCtrlSeq, BibliographyStyleCtrlSeq, CenteringCtrlSeq, @@ -632,6 +634,8 @@ const otherKnowncommands = { '\\include': IncludeCtrlSeq, '\\item': ItemCtrlSeq, '\\centering': CenteringCtrlSeq, + '\\newtheorem': NewTheoremCtrlSeq, + '\\theoremstyle': TheoremStyleCtrlSeq, '\\bibliography': BibliographyCtrlSeq, '\\bibliographystyle': BibliographyStyleCtrlSeq, '\\maketitle': MaketitleCtrlSeq, diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts index d9287542a4..c508657196 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts @@ -357,3 +357,11 @@ export function parseFigureData( graphicsCommandArguments, }) } + +export const getBeginEnvSuffix = (state: EditorState, node: SyntaxNode) => { + const argumentNode = node + .getChild('OptionalArgument') + ?.getChild('ShortOptionalArg') + + return argumentNode && state.sliceDoc(argumentNode.from, argumentNode.to) +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/theorems.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/theorems.ts new file mode 100644 index 0000000000..f8ed9ec9ee --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/theorems.ts @@ -0,0 +1,71 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode, Tree } from '@lezer/common' +import { + LongArg, + ShortArg, + ShortTextArgument, + TextArgument, +} from '../../lezer-latex/latex.terms.mjs' + +export const parseTheoremArguments = ( + state: EditorState, + node: SyntaxNode +): { name: string; label: string } | undefined => { + const nameArgumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg) + const labelArgumentNode = node.getChild(TextArgument)?.getChild(LongArg) + + if (nameArgumentNode && labelArgumentNode) { + const name = state + .sliceDoc(nameArgumentNode.from, nameArgumentNode.to) + .trim() + + const label = state + .sliceDoc(labelArgumentNode.from, labelArgumentNode.to) + .trim() + + if (name && label) { + return { name, label } + } + } +} + +export const parseTheoremStyles = (state: EditorState, tree: Tree) => { + // TODO: only scan for styles if amsthm is present? + let currentTheoremStyle = 'plain' + const theoremStyles = new Map() + const topNode = tree.topNode + if (topNode && topNode.name === 'LaTeX') { + const textNode = topNode.getChild('Text') + const topLevelCommands = textNode + ? textNode.getChildren('Command') + : topNode.getChildren('Command') + for (const command of topLevelCommands) { + const node = command.getChild('KnownCommand')?.getChild('$Command') + if (node) { + if (node.type.is('TheoremStyleCommand')) { + const theoremStyle = argumentNodeContent(state, node) + if (theoremStyle) { + currentTheoremStyle = theoremStyle + } + } else if (node.type.is('NewTheoremCommand')) { + const theoremEnvironmentName = argumentNodeContent(state, node) + if (theoremEnvironmentName) { + theoremStyles.set(theoremEnvironmentName, currentTheoremStyle) + } + } + } + } + } + return theoremStyles +} + +const argumentNodeContent = ( + state: EditorState, + node: SyntaxNode +): string | null => { + const argumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg) + + return argumentNode + ? state.sliceDoc(argumentNode.from, argumentNode.to) + : null +} diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 759b0fd695..2b8c59d450 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -540,6 +540,54 @@ describe(' in Visual mode', function () { }) }) + describe('decorates theorems', function () { + it('decorates a proof environment', function () { + cy.get('@first-line').type( + ['\\begin{{}proof}{Enter}', 'foo{Enter}', '\\end{{}proof}{Enter}'].join( + '' + ) + ) + cy.get('.cm-content').should('have.text', 'Prooffoo') + }) + + it('decorates a theorem environment', function () { + cy.get('@first-line').type( + [ + '\\begin{{}theorem}{Enter}', + 'foo{Enter}', + '\\end{{}theorem}{Enter}', + ].join('') + ) + cy.get('.cm-content').should('have.text', 'Theoremfoo') + }) + + it('decorates a theorem environment with a label', function () { + cy.get('@first-line').type( + [ + '\\begin{{}theorem}[Bar]{Enter}', + 'foo{Enter}', + '\\end{{}theorem}{Enter}', + ].join('') + ) + cy.get('.cm-content').should('have.text', 'Theorem (Bar)foo') + }) + + it('decorates a custom theorem environment with a label', function () { + cy.get('@first-line').type( + [ + '\\newtheorem{{}thm}{{}Foo}{Enter}', + '\\begin{{}thm}[Bar]{Enter}', + 'foo{Enter}', + '\\end{{}thm}{Enter}', + ].join('') + ) + cy.get('.cm-content').should( + 'have.text', + ['\\newtheorem{thm}{Foo}', 'Foo (Bar)foo'].join('') + ) + }) + }) + // TODO: \input // TODO: Math // TODO: Abstract