diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index a5ae716fb8..c048adb317 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -689,6 +689,12 @@ const ProjectController = { cb() }) }, + pasteHtmlAssignment(cb) { + SplitTestHandler.getAssignment(req, res, 'paste-html', () => { + // We'll pick up the assignment from the res.locals assignment. + cb() + }) + }, sourceEditorToolbarAssigment(cb) { SplitTestHandler.getAssignment( req, diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts new file mode 100644 index 0000000000..428e3b9dbf --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts @@ -0,0 +1,413 @@ +import { EditorView } from '@codemirror/view' +import { EditorSelection, Prec } from '@codemirror/state' + +export const pasteHtml = Prec.highest( + EditorView.domEventHandlers({ + paste(event, view) { + const { clipboardData } = event + + if (!clipboardData?.types.includes('text/html')) { + return false + } + + const html = clipboardData.getData('text/html').trim() + + if (html.length === 0) { + return false + } + + // convert the HTML to LaTeX + try { + const latex = htmlToLaTeX(html) + + view.dispatch( + view.state.changeByRange(range => { + return { + range: EditorSelection.cursor(range.from + latex.length), + changes: { from: range.from, to: range.to, insert: latex }, + } + }) + ) + + return true + } catch { + // fall back to the default paste handler + return false + } + }, + }) +) + +const removeUnwantedElements = ( + documentElement: HTMLElement, + selector: string +) => { + for (const element of documentElement.querySelectorAll(selector)) { + element.remove() + } +} + +const htmlToLaTeX = (html: string) => { + const parser = new DOMParser() + const { documentElement } = parser.parseFromString(html, 'text/html') + + // remove style elements + removeUnwantedElements(documentElement, 'style') + + // protect special characters in non-LaTeX text nodes + protectSpecialCharacters(documentElement) + + processMatchedElements(documentElement) + + const text = documentElement.textContent + + if (!text) { + return '' + } + + // normalise multiple newlines + return text.replaceAll(/\n{2,}/g, '\n\n') +} + +const protectSpecialCharacters = (documentElement: HTMLElement) => { + for (const element of documentElement.childNodes) { + const text = element.textContent + if (text) { + // if there are no code blocks, use backslash as an indicator of LaTeX code that shouldn't be protected + if ( + element instanceof HTMLElement && + !element.querySelector('code') && + text.includes('\\') + ) { + continue + } + + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent + if (text) { + // leave text that's in a code block + if (node.parentElement?.closest('code')) { + continue + } + + // replace non-backslash-prefixed characters + node.textContent = text.replaceAll( + /(^|[^\\])([#$%&~_^\\{}])/g, + (_match, prefix: string, char: string) => `${prefix}\\${char}` + ) + } + } + } + } +} + +const processMatchedElements = (documentElement: HTMLElement) => { + for (const item of selectors) { + for (const element of documentElement.querySelectorAll( + item.selector + )) { + if (!item.match || item.match(element)) { + // start the markup + if (item.start) { + const start = document.createTextNode(item.start(element)) + if (item.inside) { + element.prepend(start) + } else { + element.before(start) + } + } + + // end the markup + if (item.end) { + const end = document.createTextNode(item.end(element)) + if (item.inside) { + element.append(end) + } else { + element.after(end) + } + } + } + } + } +} + +const matchingParents = (element: HTMLElement, selector: string) => { + const matches = [] + + for ( + let ancestor = element.parentElement?.closest(selector); + ancestor; + ancestor = ancestor.parentElement?.closest(selector) + ) { + matches.push(ancestor) + } + + return matches +} + +const tabular = (element: HTMLTableElement) => { + const options = [] + + // NOTE: only analysing cells in the first row + const row = element.querySelector('tr') + + if (row) { + // TODO: look for horizontal borders and insert \hline (or \toprule, \midrule, \bottomrule etc)? + + const cells = [...row.childNodes].filter( + element => element.nodeName === 'TD' || element.nodeName === 'TH' + ) as Array + + for (const cell of cells) { + const { borderLeft, textAlign, borderRight } = cell.style + + if (borderLeft && borderLeft !== 'none') { + // avoid duplicating when both left and right borders are defined + if (options.length === 0 || options[options.length - 1] !== '|') { + options.push('|') + } + } + + options.push( + textAlign === 'left' ? 'l' : textAlign === 'right' ? 'r' : 'c' + ) + + if (borderRight && borderRight !== 'none') { + options.push('|') + } + } + } + + return options.join(' ') +} + +const listDepth = ( + element: HTMLOListElement | HTMLUListElement | HTMLLIElement +): number => Math.max(0, matchingParents(element, 'ul,ol').length - 1) + +const listIndent = ( + element: HTMLOListElement | HTMLUListElement | HTMLLIElement +): string => '\t'.repeat(listDepth(element)) + +type ElementSelector = { + selector: T + match?: (element: E) => boolean + start?: (element: E) => string + end?: (element: E) => string + inside?: boolean +} + +const createSelector = < + T extends string, + E extends HTMLElement = T extends keyof HTMLElementTagNameMap + ? HTMLElementTagNameMap[T] + : HTMLElement +>({ + selector, + ...elementSelector +}: ElementSelector) => ({ + selector, + ...elementSelector, +}) + +const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'] + +const isHeading = (element: HTMLElement | null) => { + return element && headings.includes(element.nodeName) +} + +const hasContent = (element: HTMLElement): boolean => { + return Boolean(element.textContent && element.textContent.trim().length > 0) +} + +export const selectors = [ + createSelector({ + selector: 'b', + match: element => + element.style.fontWeight !== 'normal' && + !isHeading(element.parentElement) && + hasContent(element), + start: () => '\\textbf{', + end: () => '}', + }), + createSelector({ + selector: '*', + match: element => + parseInt(element.style.fontWeight) > 400 && hasContent(element), + start: () => '\\textbf{', + end: () => '}', + inside: true, + }), + createSelector({ + selector: 'i', + match: element => + element.style.fontStyle !== 'normal' && hasContent(element), + start: () => '\\textit{', + end: () => '}', + }), + createSelector({ + selector: '*', + match: element => + element.style.fontStyle === 'italic' && hasContent(element), + start: () => '\\textit{', + end: () => '}', + }), + createSelector({ + selector: 'sup', + match: element => hasContent(element), + start: () => '\\textsuperscript{', + end: () => '}', + }), + createSelector({ + selector: 'span', + match: element => + element.style.verticalAlign === 'super' && hasContent(element), + start: () => '\\textsuperscript{', + end: () => '}', + }), + createSelector({ + selector: 'sub', + match: element => hasContent(element), + start: () => '\\textsubscript{', + end: () => '}', + }), + createSelector({ + selector: 'span', + match: element => + element.style.verticalAlign === 'sub' && hasContent(element), + start: () => '\\textsubscript{', + end: () => '}', + }), + createSelector({ + selector: 'a', + match: element => !!element.href && hasContent(element), + start: (element: HTMLAnchorElement) => `\\href{${element.href}}{`, + end: element => `}`, + }), + createSelector({ + selector: 'h1', + match: element => !element.closest('table') && hasContent(element), + start: () => `\n\n\\section{`, + end: () => `}\n\n`, + }), + createSelector({ + selector: 'h2', + match: element => !element.closest('table') && hasContent(element), + start: () => `\n\n\\subsection{`, + end: () => `}\n\n`, + }), + createSelector({ + selector: 'h3', + match: element => !element.closest('table') && hasContent(element), + start: () => `\n\n\\subsubsection{`, + end: () => `}\n\n`, + }), + createSelector({ + selector: 'h4', + match: element => !element.closest('table') && hasContent(element), + start: () => `\n\n\\paragraph{`, + end: () => `}\n\n`, + }), + createSelector({ + selector: 'h5', + match: element => !element.closest('table') && hasContent(element), + start: () => `\n\n\\subparagraph{`, + end: () => `}\n\n`, + }), + // TODO: h6? + createSelector({ + selector: 'br', + match: element => element.parentElement?.nodeName !== 'TD', // TODO: why? + start: () => `\n\n`, + }), + createSelector({ + selector: 'code', + match: element => + element.parentElement?.nodeName !== 'PRE' && hasContent(element), + start: () => `\\verb|`, + end: () => `|`, + }), + createSelector({ + selector: 'pre > code', + match: element => hasContent(element), + start: () => `\n\n\\begin{verbatim}\n`, + end: () => `\n\\end{verbatim}\n\n`, + }), + createSelector({ + selector: 'table', + start: element => + `\n\n\\begin{table}\n\\centering\n\\begin{tabular}{${tabular( + element + )}}\n`, + end: () => `\n\\end{tabular}\n\\end{table}\n\n`, + }), + createSelector({ + selector: 'thead', + start: () => `\n`, + end: () => `\n`, + }), + createSelector({ + selector: 'tfoot', + start: () => `\n`, + end: () => `\n`, + }), + createSelector({ + selector: 'tbody', + start: () => `\n`, + end: () => `\n`, + }), + createSelector({ + selector: 'tr', + match: element => element.nextElementSibling?.nodeName === 'TR', + end: () => `\n`, + }), + createSelector({ + selector: 'tr > td:not(:last-child), tr > th:not(:last-child)', + start: element => { + const colspan = element.getAttribute('colspan') + return colspan ? `\\multicolumn{${Number(colspan)}}{` : '' + }, + end: element => { + const colspan = element.getAttribute('colspan') + return colspan ? `} & ` : ` & ` + }, + }), + createSelector({ + selector: 'tr > td:last-child, tr > th:last-child', + start: element => { + const colspan = element.getAttribute('colspan') + return colspan ? `\\multicolumn{${Number(colspan)}}{` : '' + }, + end: element => { + const colspan = element.getAttribute('colspan') + return colspan ? `} \\\\` : ` \\\\` + }, + }), + createSelector({ + selector: 'table > caption', + start: () => `\n\n\\caption{\\label{tab:example}`, + end: () => `}\n\n`, + }), + createSelector({ + selector: 'ul', + start: element => `\n\n${listIndent(element)}\\begin{itemize}`, + end: element => `\n${listIndent(element)}\\end{itemize}\n`, + }), + createSelector({ + selector: 'ol', + start: element => `\n\n${listIndent(element)}\\begin{enumerate}`, + end: element => `\n${listIndent(element)}\\end{enumerate}\n`, + }), + createSelector({ + selector: 'li', + start: element => `\n${listIndent(element)}\t\\item `, + }), + createSelector({ + selector: 'p', + match: element => + element.nextElementSibling?.nodeName === 'P' && hasContent(element), + end: () => '\n\n', + }), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts index 8bf88d4ef5..fa5d2c59be 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -24,6 +24,7 @@ import { figureModalPasteHandler } from '../figure-modal' import { isSplitTestEnabled } from '../../../../utils/splitTestUtils' import { toolbarPanel } from '../toolbar/toolbar-panel' import { selectDecoratedArgument } from './select-decorated-argument' +import { pasteHtml } from './paste-html' type Options = { visual: boolean @@ -204,4 +205,5 @@ const extension = (options: Options) => [ selectDecoratedArgument, showContentWhenParsed, figureModalPasteHandler(), + isSplitTestEnabled('paste-html') ? pasteHtml : [], ] diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx new file mode 100644 index 0000000000..33effeee24 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -0,0 +1,235 @@ +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 = '') => { + const scope = mockScope(content) + scope.editor.showVisual = true + + cy.mount( + + + + + + ) + + // wait for the content to be parsed and revealed + cy.get('.cm-content').as('content') + cy.get('@content').should('have.css', 'opacity', '1') +} + +describe(' paste HTML in Visual mode', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + window.metaAttributesCache.set('ol-splitTestVariants', { + 'paste-html': 'enabled', + }) + cy.interceptEvents() + cy.interceptSpelling() + }) + + it('handles paste', function () { + mountEditor() + + const data = 'foo' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', 'foo') + cy.get('@get-data').should('have.been.calledOnceWithExactly', 'text/html') + }) + + it('handles a pasted bullet list', function () { + mountEditor() + + const data = '
  • foo
  • bar
' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', ' foo bar') + cy.get('.ol-cm-item').should('have.length', 2) + }) + + it('handles a pasted numbered list', function () { + mountEditor() + + const data = '
  1. foo
  2. bar
' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', ' foo bar') + cy.get('.ol-cm-item').should('have.length', 2) + }) + + it('handles a pasted simple table', function () { + mountEditor() + + const data = + '
foobar
' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + '\\begin{tabular}{c c}foo & bar ↩\\end{tabular}' + ) + }) + + it('handles a pasted simple table with borders', function () { + mountEditor() + + const data = + '
foobar
' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + '\\begin{tabular}{| c | c |}foo & bar ↩\\end{tabular}' + ) + }) + + it('handles a pasted table with merged cells', function () { + mountEditor() + + const data = [ + ``, + ``, + ``, + ``, + `
testtesttest
testtest
testtest
`, + ].join('') + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + '\\begin{tabular}{c c c}test & test & test ↩\\multicolumn{2}{test} & test ↩test & \\multicolumn{2}{test} ↩\\end{tabular}' + ) + }) + + it('handles a pasted link', function () { + mountEditor() + + const data = 'foo' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', '\\href{https://example.com/}{foo}') + cy.get('.ol-cm-command-href').should('have.length', 1) + }) + + it('handles a pasted code block', function () { + mountEditor() + + const data = '
foo
' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', 'foo') + cy.get('.ol-cm-environment-verbatim').should('have.length', 5) + + cy.get('.cm-line').eq(2).click() + cy.get('@content').should( + 'have.text', + '\\begin{verbatim}foo\\end{verbatim}' + ) + }) + + it('handles pasted inline code', function () { + mountEditor() + + const data = 'foo' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', '\\verb|foo|') + cy.get('.ol-cm-command-verb').should('have.length', 1) + }) + + it('handles pasted text with formatting', function () { + mountEditor() + + const data = 'footh bar2' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + 'foo\\textsuperscript{th} bar\\textsubscript{2}' + ) + cy.get('.ol-cm-command-textbf').should('have.length', 1) + cy.get('.ol-cm-command-textit').should('have.length', 1) + }) + + it('protects special characters', function () { + mountEditor() + + const data = 'foo & bar~baz' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should('have.text', 'foo & bar~baz') + cy.get('.ol-cm-character').should('have.length', 2) + }) + + it('does not protect special characters in code blocks', function () { + mountEditor() + + const data = 'foo & bar~baz \\textbf{foo}' + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.spy(clipboardData, 'getData').as('get-data') + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + 'foo & bar~baz \\verb|\\textbf{foo}|' + ) + + cy.get('.cm-line').eq(0).type('{Enter}') + cy.get('@content').should('have.text', 'foo & bar~baz \\textbf{foo}') + cy.get('.ol-cm-character').should('have.length', 2) + cy.get('.ol-cm-command-verb').should('have.length', 1) + }) +})