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 bccea4296b..54d3eb4a00 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 @@ -47,6 +47,7 @@ import getMeta from '../../../../utils/meta' import { EditableGraphicsWidget } from './visual-widgets/editable-graphics' import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-graphics' import { CloseBrace, OpenBrace } from '../../lezer-latex/latex.terms.mjs' +import { FootnoteWidget } from './visual-widgets/footnote' type Options = { fileTreeManager: { @@ -857,6 +858,43 @@ export const atomicDecorations = (options: Options) => { ) return false } + } else if ( + commandName === '\\footnote' || + commandName === '\\endnote' + ) { + if (textArgumentNode) { + if ( + state.readOnly && + selectionIntersects(state.selection, nodeRef) + ) { + // a special case for a read-only document: + // always display the content, styled differently from the main content. + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + textArgumentNode, + nodeRef.from + ), + Decoration.mark({ + class: 'ol-cm-footnote ol-cm-footnote-view', + }).range(textArgumentNode.from, textArgumentNode.to) + ) + } else { + if (shouldDecorate(state, nodeRef)) { + // collapse the footnote when the selection is outside it + decorations.push( + Decoration.replace({ + widget: new FootnoteWidget( + commandName === '\\footnote' + ? 'footnote' + : 'endnote' + ), + }).range(nodeRef.from, nodeRef.to) + ) + return false + } + } + } } else if (commandName === '\\LaTeX') { if (shouldDecorate(state, nodeRef)) { decorations.push( 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 acc656dbd2..6264749781 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 @@ -360,4 +360,22 @@ export const visualTheme = EditorView.theme({ top: '18px', right: '18px', }, + '.ol-cm-footnote': { + display: 'inline-flex', + padding: '0 0.1em', + background: 'rgba(125, 125, 125, 0.25)', + borderRadius: '2px', + height: '1em', + cursor: 'pointer', + verticalAlign: 'text-top', + '&:not(.ol-cm-footnote-view):hover': { + background: 'rgba(125, 125, 125, 0.5)', + }, + '&.ol-cm-footnote-view': { + height: 'auto', + verticalAlign: 'unset', + display: 'inline', + padding: '0 0.5em', + }, + }, }) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/footnote.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/footnote.ts new file mode 100644 index 0000000000..2c8622dc78 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/footnote.ts @@ -0,0 +1,35 @@ +import { WidgetType } from '@codemirror/view' + +type NoteType = 'footnote' | 'endnote' + +const symbols: Record = { + footnote: '*', + endnote: '†', +} + +export class FootnoteWidget extends WidgetType { + constructor(private type: NoteType = 'footnote') { + super() + } + + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-footnote') + element.setAttribute('role', 'button') + element.innerHTML = symbols[this.type] + return element + } + + eq(widget: FootnoteWidget) { + return this.type === widget.type + } + + updateDOM(element: HTMLElement): boolean { + element.innerHTML = symbols[this.type] + return true + } + + ignoreEvent(event: Event) { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } +} diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx new file mode 100644 index 0000000000..b8e3875542 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-readonly.spec.tsx @@ -0,0 +1,44 @@ +import { mockScope } from '../helpers/mock-scope' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { FC } from 'react' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe(' in Visual mode with read-only permission', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + window.metaAttributesCache.set( + 'ol-mathJax3Path', + 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js' + ) + cy.interceptEvents() + cy.interceptSpelling() + }) + + it('decorates footnote content', function () { + const scope = mockScope('Foo \\footnote{Bar.} ') + scope.permissionsLevel = 'readOnly' + scope.editor.showVisual = true + + cy.mount( + + + + + + ) + + // wait for the content to be parsed and revealed + cy.get('.cm-content').should('have.css', 'opacity', '1') + + // select the footnote, so it expands + cy.get('.ol-cm-footnote').click() + + cy.get('.cm-line').eq(0).as('first-line') + cy.get('@first-line').should('contain', 'Foo') + cy.get('@first-line').should('contain', 'Bar') + }) +}) 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 4b4a035754..12ec483590 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 @@ -10,7 +10,7 @@ const Container: FC = ({ children }) => (
{children}
) -describe(' in Rich Text mode', function () { +describe(' in Visual mode', function () { beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) window.metaAttributesCache.set( @@ -46,6 +46,10 @@ describe(' in Rich Text mode', function () { cy.get('@first-line').click() }) + afterEach(function () { + window.metaAttributesCache.clear() + }) + forEach(['LaTeX', 'TeX']).it('renders the %s logo', function (logo) { cy.get('@first-line').type(`\\${logo}{{}}{Enter}`) cy.get('@first-line').should('have.text', logo) @@ -374,6 +378,14 @@ describe(' in Rich Text mode', function () { cy.get('.ol-cm-author').should('have.text', 'Author') }) + it('decorates footnotes', function () { + cy.get('@first-line').type('Foo \\footnote{{}Bar.} ') + cy.get('@first-line').should('contain', 'Foo') + cy.get('@first-line').should('not.contain', 'Bar') + cy.get('@first-line').type('{leftArrow}') + cy.get('@first-line').should('have.text', 'Foo \\footnote{Bar.} ') + }) + // TODO: \input // TODO: Math // TODO: Abstract