mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
[visual] Convert pasted HTML to LaTeX (#12094)
GitOrigin-RevId: a2fd4411ab8655eea834d321025efb9644081252
This commit is contained in:
parent
e90ffd2b54
commit
8f1de5fa09
4 changed files with 656 additions and 0 deletions
|
@ -689,6 +689,12 @@ const ProjectController = {
|
||||||
cb()
|
cb()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
pasteHtmlAssignment(cb) {
|
||||||
|
SplitTestHandler.getAssignment(req, res, 'paste-html', () => {
|
||||||
|
// We'll pick up the assignment from the res.locals assignment.
|
||||||
|
cb()
|
||||||
|
})
|
||||||
|
},
|
||||||
sourceEditorToolbarAssigment(cb) {
|
sourceEditorToolbarAssigment(cb) {
|
||||||
SplitTestHandler.getAssignment(
|
SplitTestHandler.getAssignment(
|
||||||
req,
|
req,
|
||||||
|
|
|
@ -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<any>(
|
||||||
|
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<HTMLTableCellElement | HTMLTableHeaderCellElement>
|
||||||
|
|
||||||
|
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<T extends string, E extends HTMLElement = HTMLElement> = {
|
||||||
|
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<T, E>) => ({
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
]
|
|
@ -24,6 +24,7 @@ import { figureModalPasteHandler } from '../figure-modal'
|
||||||
import { isSplitTestEnabled } from '../../../../utils/splitTestUtils'
|
import { isSplitTestEnabled } from '../../../../utils/splitTestUtils'
|
||||||
import { toolbarPanel } from '../toolbar/toolbar-panel'
|
import { toolbarPanel } from '../toolbar/toolbar-panel'
|
||||||
import { selectDecoratedArgument } from './select-decorated-argument'
|
import { selectDecoratedArgument } from './select-decorated-argument'
|
||||||
|
import { pasteHtml } from './paste-html'
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
visual: boolean
|
visual: boolean
|
||||||
|
@ -204,4 +205,5 @@ const extension = (options: Options) => [
|
||||||
selectDecoratedArgument,
|
selectDecoratedArgument,
|
||||||
showContentWhenParsed,
|
showContentWhenParsed,
|
||||||
figureModalPasteHandler(),
|
figureModalPasteHandler(),
|
||||||
|
isSplitTestEnabled('paste-html') ? pasteHtml : [],
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 }) => (
|
||||||
|
<div style={{ width: 785, height: 785 }}>{children}</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const mountEditor = (content = '') => {
|
||||||
|
const scope = mockScope(content)
|
||||||
|
scope.editor.showVisual = true
|
||||||
|
|
||||||
|
cy.mount(
|
||||||
|
<Container>
|
||||||
|
<EditorProviders scope={scope}>
|
||||||
|
<CodemirrorEditor />
|
||||||
|
</EditorProviders>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
|
||||||
|
// wait for the content to be parsed and revealed
|
||||||
|
cy.get('.cm-content').as('content')
|
||||||
|
cy.get('@content').should('have.css', 'opacity', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<CodeMirrorEditor/> 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 = '<ul><li>foo</li><li>bar</li></ul>'
|
||||||
|
|
||||||
|
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 = '<ol><li>foo</li><li>bar</li></ol>'
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<table><tbody><tr><td>foo</td><td>bar</td></tr></tbody></table>'
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<table><tbody><tr><td style="border-left:1px solid black;border-right:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black">bar</td></tr></tbody></table>'
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
`<table><tbody>`,
|
||||||
|
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
|
||||||
|
`<tr><td colspan="2">test</td><td>test</td></tr>`,
|
||||||
|
`<tr><td>test</td><td colspan="2">test</td></tr>`,
|
||||||
|
`</tbody></table>`,
|
||||||
|
].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 = '<a href="https://example.com/">foo</a>'
|
||||||
|
|
||||||
|
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 = '<pre><code>foo</a></pre>'
|
||||||
|
|
||||||
|
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 = '<code>foo</a>'
|
||||||
|
|
||||||
|
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 = '<b>foo</b><sup>th</sup> <i>bar</i><sub>2</sub>'
|
||||||
|
|
||||||
|
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 <code>\\textbf{foo}</code>'
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue