diff --git a/services/web/cypress/support/shared/commands/index.ts b/services/web/cypress/support/shared/commands/index.ts index 41433ba258..f9b3295547 100644 --- a/services/web/cypress/support/shared/commands/index.ts +++ b/services/web/cypress/support/shared/commands/index.ts @@ -10,6 +10,7 @@ import { interceptAsync } from './intercept-async' import { interceptFileUpload } from './upload' import { interceptProjectListing } from './project-list' import { interceptLinkedFile } from './linked-file' +import { interceptMathJax } from './mathjax' // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-namespace declare global { @@ -26,6 +27,7 @@ declare global { interceptFileUpload: typeof interceptFileUpload interceptProjectListing: typeof interceptProjectListing interceptLinkedFile: typeof interceptLinkedFile + interceptMathJax: typeof interceptMathJax } } } @@ -39,3 +41,4 @@ Cypress.Commands.add('interceptDeferredCompile', interceptDeferredCompile) Cypress.Commands.add('interceptFileUpload', interceptFileUpload) Cypress.Commands.add('interceptProjectListing', interceptProjectListing) Cypress.Commands.add('interceptLinkedFile', interceptLinkedFile) +Cypress.Commands.add('interceptMathJax', interceptMathJax) diff --git a/services/web/cypress/support/shared/commands/mathjax.ts b/services/web/cypress/support/shared/commands/mathjax.ts new file mode 100644 index 0000000000..b5a5f8a830 --- /dev/null +++ b/services/web/cypress/support/shared/commands/mathjax.ts @@ -0,0 +1,23 @@ +const MATHJAX_STUB = ` +window.MathJax = { + startup: { + promise: Promise.resolve() + }, + svgStylesheet: () => document.createElement("STYLE") +} +` + +export const interceptMathJax = () => { + cy.window().then(win => { + win.metaAttributesCache.set( + 'ol-mathJax3Path', + 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js' + ) + }) + cy.intercept('GET', '/js/libs/mathjax3/es5/tex-svg-full.js*', MATHJAX_STUB) + cy.intercept( + 'GET', + 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js', + MATHJAX_STUB + ) +} diff --git a/services/web/frontend/js/features/mathjax/load-mathjax.ts b/services/web/frontend/js/features/mathjax/load-mathjax.ts index 81162f6683..eb4b602064 100644 --- a/services/web/frontend/js/features/mathjax/load-mathjax.ts +++ b/services/web/frontend/js/features/mathjax/load-mathjax.ts @@ -40,7 +40,12 @@ export const loadMathJax = async () => { } const script = document.createElement('script') - script.src = getMeta('ol-mathJax3Path') + const path = getMeta('ol-mathJax3Path') + if (!path) { + reject(new Error('No MathJax path found')) + return + } + script.src = path script.addEventListener('load', async () => { await window.MathJax.startup.promise document.head.appendChild(window.MathJax.svgStylesheet()) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx index b577e8a21d..168f74eb8d 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/cell.tsx @@ -157,11 +157,13 @@ export const Cell: FC<{ renderDiv.current, toDisplay.substring.bind(toDisplay) ) - loadMathJax().then(async MathJax => { - if (renderDiv.current) { - await MathJax.typesetPromise([renderDiv.current]) - } - }) + loadMathJax() + .then(async MathJax => { + if (renderDiv.current) { + await MathJax.typesetPromise([renderDiv.current]) + } + }) + .catch(() => {}) } }, [cellData.content, editing]) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx index b9aacd1e2c..f06a7c6789 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/table.tsx @@ -172,6 +172,15 @@ export const Table: FC = () => { ref={tableRef} > + {/* A workaround for a chrome bug where it will not respect colspan + unless there is a row filled with cells without colspan */} + + {/* A td for the row selector */} + + {tableData.columns.map((_, columnIndex) => ( + + ))} + {tableData.columns.map((_, columnIndex) => ( @@ -188,15 +197,6 @@ export const Table: FC = () => { columnSpecifications={tableData.columns} /> ))} - {/* A workaround for a chrome bug where it will not respect colspan - unless there is a row filled with cells without colspan */} - - {/* A td for the row selector */} - - {tableData.columns.map((_, columnIndex) => ( - - ))} - ) diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts index 4dc73b535e..37b6289955 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts @@ -340,6 +340,7 @@ export const insertRow = ( selection: TableSelection, positions: Positions, below: boolean, + rowSeparators: RowSeparator[], table: TableData ) => { const { maxY, minY } = selection.normalized() @@ -350,11 +351,13 @@ export const insertRow = ( const numberOfColumns = table.columns.length const borderTheme = table.getBorderTheme() const border = borderTheme === BorderTheme.FULLY_BORDERED ? '\\hline' : '' + const initialRowSeparator = + below && rowSeparators.length === table.rows.length - 1 ? '\\\\' : '' const initialHline = borderTheme === BorderTheme.FULLY_BORDERED && !below && minY === 0 ? '\\hline' : '' - const insert = `${initialHline}\n${' &'.repeat( + const insert = `${initialRowSeparator}${initialHline}\n${' &'.repeat( numberOfColumns - 1 )}\\\\${border}`.repeat(rowsToInsert) view.dispatch({ changes: { from, to: from, insert } }) @@ -599,7 +602,7 @@ export const mergeCells = ( } const cellContent = [] for (let i = minX; i <= maxX; i++) { - cellContent.push(table.getCell(minY, i).content) + cellContent.push(table.getCell(minY, i).content.trim()) } const content = cellContent.join(' ').trim() const border = diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx index 04136d398a..661d3d89ec 100644 --- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar.tsx @@ -266,7 +266,16 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertRow(view, selection, positions, false, table)) + setSelection( + insertRow( + view, + selection, + positions, + false, + rowSeparators, + table + ) + ) }} > @@ -280,7 +289,16 @@ export const Toolbar = memo(function Toolbar() { role="menuitem" type="button" onClick={() => { - setSelection(insertRow(view, selection, positions, true, table)) + setSelection( + insertRow( + view, + selection, + positions, + true, + rowSeparators, + table + ) + ) }} > diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx new file mode 100644 index 0000000000..de0dcbb7ea --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-table-generator.spec.tsx @@ -0,0 +1,391 @@ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' + +const Container: FC = ({ children }) => ( +
{children}
+) + +const mountEditor = (content: string | string[]) => { + if (Array.isArray(content)) { + content = content.join('\n') + } + if (!content.startsWith('\n')) { + content = '\n' + content + } + const scope = mockScope(content) + scope.editor.showVisual = true + + cy.mount( + + + + + + ) + + // wait for the content to be parsed and revealed + cy.get('.cm-content').should('have.css', 'opacity', '1') + cy.get('.cm-line').first().click() +} + +function checkTable( + expected: (string | { text: string; colspan: number })[][] +) { + cy.get('.table-generator').as('table').should('exist') + cy.get('@table') + .find('tbody') + .as('body') + .find('tr') + .should('have.length', expected.length) + cy.get('@body') + .find('tr') + .each((row, rowIndex) => { + // Add one to the expected length to account for the row selector + cy.wrap(row) + .find('.table-generator-cell') + .as('cells') + .should('have.length', expected[rowIndex].length) + cy.get('@cells').each((cell, cellIndex) => { + const expectation = expected[rowIndex][cellIndex] + const cellText = + typeof expectation === 'string' ? expectation : expectation.text + const colspan = + typeof expectation === 'string' ? undefined : expectation.colspan + cy.wrap(cell).should('contain.text', cellText) + if (colspan) { + cy.wrap(cell).should('have.attr', 'colspan', colspan.toString()) + } + }) + }) +} + +function checkBordersWithNoMultiColumn( + verticalBorderIndices: boolean[], + horizontalBorderIndices: boolean[] +) { + cy.get('.table-generator').as('table').should('have.length', 1) + cy.get('@table') + .find('tbody') + .as('body') + .find('tr') + .should('have.length', verticalBorderIndices.length - 1) + .each((row, rowIndex) => { + cy.wrap(row) + .find('.table-generator-cell') + .should('have.length', horizontalBorderIndices.length - 1) + .each((cell, cellIndex) => { + if (cellIndex === 0) { + cy.wrap(cell).should( + horizontalBorderIndices[0] ? 'have.class' : 'not.have.class', + 'table-generator-cell-border-left' + ) + } + cy.wrap(cell).should( + horizontalBorderIndices[cellIndex + 1] + ? 'have.class' + : 'not.have.class', + 'table-generator-cell-border-right' + ) + cy.wrap(cell).should( + verticalBorderIndices[rowIndex] ? 'have.class' : 'not.have.class', + 'table-generator-row-border-top' + ) + if (rowIndex === verticalBorderIndices.length - 2) { + cy.wrap(cell).should( + verticalBorderIndices[rowIndex + 1] + ? 'have.class' + : 'not.have.class', + 'table-generator-row-border-bottom' + ) + } + }) + }) +} + +describe(' Table editor', function () { + beforeEach(function () { + cy.interceptEvents() + cy.interceptSpelling() + cy.interceptMathJax() + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + window.metaAttributesCache.set('ol-splitTestVariants', { + 'table-generator': 'enabled', + }) + }) + + describe('Table rendering', function () { + it('Renders a simple table', function () { + mountEditor(` +\\begin{tabular}{ccc} + cell 1 & cell 2 & cell 3 \\\\ + cell 4 & cell 5 & cell 6 \\\\ +\\end{tabular}`) + + // Find cell in table + checkTable([ + ['cell 1', 'cell 2', 'cell 3'], + ['cell 4', 'cell 5', 'cell 6'], + ]) + }) + + it('Renders a table with \\multicolumn', function () { + mountEditor(` +\\begin{tabular}{ccc} + \\multicolumn{2}{c}{cell 1 and cell 2} & cell 3 \\\\ + cell 4 & cell 5 & cell 6 \\\\ +\\end{tabular}`) + + // Find cell in table + checkTable([ + [{ text: 'cell 1 and cell 2', colspan: 2 }, 'cell 3'], + ['cell 4', 'cell 5', 'cell 6'], + ]) + }) + + it('Renders borders', function () { + mountEditor(` +\\begin{tabular}{c|c} +cell 1 & cell 2 \\\\ +\\hline +cell 3 & cell 4 \\\\ +\\end{tabular}`) + + checkBordersWithNoMultiColumn([false, true, false], [false, true, false]) + }) + }) + + describe('The toolbar', function () { + it('Renders the toolbar when the table is selected', function () { + mountEditor(` +\\begin{tabular}{c} + cell +\\end{tabular} + `) + cy.get('.table-generator-floating-toolbar').should('not.exist') + cy.get('.table-generator-cell').click() + cy.get('.table-generator-floating-toolbar').should('exist') + // The element is partially covered, but we can still click it + cy.get('.cm-line').first().click({ force: true }) + cy.get('.table-generator-floating-toolbar').should('not.exist') + }) + + it('Adds and removes borders when theme is changed', function () { + mountEditor(` +\\begin{tabular}{c|c} + cell 1 & cell 2 \\\\ + cell 3 & cell 4 \\\\ +\\end{tabular} + `) + checkBordersWithNoMultiColumn([false, false, false], [false, true, false]) + cy.get('.table-generator-floating-toolbar').should('not.exist') + cy.get('.table-generator-cell').first().click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByText('Custom borders').click() + cy.get('.table-generator').findByText('All borders').click() + // The element is partially covered, but we can still click it + cy.get('.cm-line').first().click({ force: true }) + // Table should be unchanged + checkTable([ + ['cell 1', 'cell 2'], + ['cell 3', 'cell 4'], + ]) + checkBordersWithNoMultiColumn([true, true, true], [true, true, true]) + + cy.get('.table-generator-cell').first().click() + cy.get('@toolbar').findByText('All borders').click() + cy.get('.table-generator').findByText('No borders').click() + // The element is partially covered, but we can still click it + cy.get('.cm-line').first().click({ force: true }) + // Table should be unchanged + checkTable([ + ['cell 1', 'cell 2'], + ['cell 3', 'cell 4'], + ]) + checkBordersWithNoMultiColumn( + [false, false, false], + [false, false, false] + ) + }) + + it('Changes the column alignment with dropdown buttons', function () { + mountEditor(` +\\begin{tabular}{cc} + cell 1 & cell 2 \\\\ + cell 3 & cell 4 \\\\ +\\end{tabular} + `) + + cy.get('.table-generator-cell') + .should('have.class', 'alignment-center') + .first() + .click() + + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByLabelText('Alignment').should('be.disabled') + cy.get('.column-selector').first().click() + cy.get('@toolbar') + .findByLabelText('Alignment') + .should('not.be.disabled') + .click() + cy.get('.table-generator').findByLabelText('Left').click() + // The element is partially covered, but we can still click it + cy.get('.cm-line').first().click({ force: true }) + // Table contents shouldn't have changed + checkTable([ + ['cell 1', 'cell 2'], + ['cell 3', 'cell 4'], + ]) + cy.get('.table-generator-cell') + .eq(0) + .should('have.class', 'alignment-left') + cy.get('.table-generator-cell') + .eq(1) + .should('have.class', 'alignment-center') + cy.get('.table-generator-cell') + .eq(2) + .should('have.class', 'alignment-left') + cy.get('.table-generator-cell') + .eq(3) + .should('have.class', 'alignment-center') + }) + + it('Removes rows and columns', function () { + mountEditor(` +\\begin{tabular}{ccc} + cell 1 & cell 2 & cell 3 \\\\ + cell 4 & cell 5 & cell 6 \\\\ + cell 7 & cell 8 & cell 9 \\\\ +\\end{tabular} + `) + checkTable([ + ['cell 1', 'cell 2', 'cell 3'], + ['cell 4', 'cell 5', 'cell 6'], + ['cell 7', 'cell 8', 'cell 9'], + ]) + cy.get('.table-generator-cell').first().click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar') + .findByLabelText('Delete row or column') + .should('be.disabled') + cy.get('.column-selector').eq(1).click() + cy.get('@toolbar').findByLabelText('Delete row or column').click() + checkTable([ + ['cell 1', 'cell 3'], + ['cell 4', 'cell 6'], + ['cell 7', 'cell 9'], + ]) + cy.get('.row-selector').eq(1).click() + cy.get('@toolbar').findByLabelText('Delete row or column').click() + checkTable([ + ['cell 1', 'cell 3'], + ['cell 7', 'cell 9'], + ]) + }) + + it('Merges and unmerged cells', function () { + mountEditor(` +\\begin{tabular}{ccc} + cell 1 & cell 2 & cell 3 \\\\ + cell 4 & cell 5 & cell 6 \\\\ +\\end{tabular} + `) + cy.get('.table-generator-cell').first().click() + cy.get('.table-generator-cell').first().type('{shift}{rightarrow}') + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByLabelText('Merge cells').click() + checkTable([ + [{ text: 'cell 1 cell 2', colspan: 2 }, 'cell 3'], + ['cell 4', 'cell 5', 'cell 6'], + ]) + cy.get('@toolbar').findByLabelText('Unmerge cells').click() + checkTable([ + ['cell 1 cell 2', '', 'cell 3'], + ['cell 4', 'cell 5', 'cell 6'], + ]) + }) + + it('Adds rows and columns', function () { + mountEditor(` +\\begin{tabular}{c} + cell 1 +\\end{tabular} + `) + cy.get('.table-generator').findByText('cell 1').click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + + cy.get('.table-generator').findByText('cell 1').click() + cy.get('@toolbar').findByLabelText('Insert').click() + cy.get('.table-generator').findByText('Insert column left').click() + checkTable([['', 'cell 1']]) + + cy.get('.table-generator').findByText('cell 1').click() + cy.get('@toolbar').findByLabelText('Insert').click() + cy.get('.table-generator').findByText('Insert column right').click() + checkTable([['', 'cell 1', '']]) + + cy.get('.table-generator').findByText('cell 1').click() + cy.get('@toolbar').findByLabelText('Insert').click() + cy.get('.table-generator').findByText('Insert row above').click() + checkTable([ + ['', '', ''], + ['', 'cell 1', ''], + ]) + + cy.get('.table-generator').findByText('cell 1').click() + cy.get('@toolbar').findByLabelText('Insert').click() + cy.get('.table-generator').findByText('Insert row below').click() + checkTable([ + ['', '', ''], + ['', 'cell 1', ''], + ['', '', ''], + ]) + }) + + it('Removes the table on toolbar button click', function () { + mountEditor(` +\\begin{tabular}{c} + cell 1 +\\end{tabular}`) + cy.get('.table-generator').findByText('cell 1').click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByLabelText('Delete table').click() + cy.get('.table-generator').should('not.exist') + }) + + it('Moves the caption when using dropdown', function () { + mountEditor(` +\\begin{table} + \\caption{Table caption} + \\label{tab:table} + \\begin{tabular}{c} + cell 1 + \\end{tabular} +\\end{table}`) + cy.get('.table-generator').findByText('cell 1').click() + cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist') + cy.get('@toolbar').findByText('Caption above').click() + cy.get('.table-generator-toolbar-dropdown-menu') + .findByText('Caption below') + .click() + // Check that caption is below table + cy.get('.ol-cm-command-caption').then(([caption]) => { + const { top: captionYPosition } = caption.getBoundingClientRect() + cy.get('.table-generator').then(([table]) => { + const { top: tableYPosition } = table.getBoundingClientRect() + cy.wrap(captionYPosition).should('be.greaterThan', tableYPosition) + }) + }) + + // Removes caption when clicking "No caption" + cy.get('@toolbar').findByText('Caption below').click() + cy.get('.table-generator-toolbar-dropdown-menu') + .findByText('No caption') + .click() + cy.get('@toolbar').findByText('No caption').should('exist') + cy.get('.ol-cm-command-caption').should('not.exist') + cy.get('.ol-cm-command-label').should('not.exist') + }) + }) +})