first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,990 @@
import { Folder } from '../../../../../types/folder'
import { docId, mockDocContent } from '../helpers/mock-doc'
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
import { FC } from 'react'
import { MetadataContext } from '@/features/ide-react/context/metadata-context'
import { ReferencesContext } from '@/features/ide-react/context/references-context'
describe('autocomplete', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set('ol-showSymbolPalette', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('opens autocomplete on matched text', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [
{
_id: 'test-folder-id',
name: 'test-folder',
docs: [
{
_id: 'test-doc-in-folder',
name: 'example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-folder',
name: 'example.png',
hash: '42',
},
],
folders: [],
},
],
fileRefs: [
{
_id: 'test-image-file',
name: 'frog.jpg',
hash: '21',
},
{
_id: 'uppercase-extension-image-file',
name: 'frog.JPG',
hash: '22',
},
],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// single backslash
cy.get('@line').type('\\')
// autocomplete
cy.findAllByRole('listbox').should('have.length', 1)
// another backslash
cy.get('@line').type('\\')
// no autocomplete open
cy.findAllByRole('listbox').should('have.length', 0)
// a space then another backslash
cy.get('@line').type(' \\')
// autocomplete open
cy.findAllByRole('listbox').should('have.length', 1)
// start a command
cy.get('@line').type('includegr')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: '\\includegraphics[]{}',
// selected: true,
// })
// .click()
cy.contains('\\includegraphics[]{}').click()
// start a command in the optional argument
cy.get('@line').type('width=0.3\\text')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: '\\textwidth',
// selected: false,
// })
// .click()
cy.contains('\\textwidth').click()
// move to required argument and start a label
cy.get('@line')
// .type('{tab}') // Tab not supported in Cypress
.type('{rightArrow}{rightArrow}')
cy.get('@line').type('fr')
// select option from autocomplete
// disabled as selector not working (Cypress bug?)
// cy.findByRole('listbox')
// .findByRole('option', {
// name: 'frog.jpg',
// selected: true,
// })
// .click()
cy.contains('frog.jpg').click()
cy.contains('\\includegraphics[width=0.3\\textwidth]{frog.jpg}')
// start a new line and select an "includegraphics" command completion
cy.get('@line').type('{Enter}')
activeEditorLine().type('\\includegr')
cy.contains('\\includegraphics[]{}').click()
// select a completion for a file in a folder, without typing the folder name
activeEditorLine()
.type('{rightArrow}{rightArrow}')
.type('examp')
.type('{Backspace}')
.type('ple')
cy.contains('test-folder/example.png').click()
cy.contains('\\includegraphics[]{test-folder/example.png}')
activeEditorLine()
.type(`${'{leftArrow}'.repeat('test-folder/example.png}'.length)}fr`)
.type('{ctrl+ }')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findByRole('listbox').contains('frog.JPG').click()
activeEditorLine().should('have.text', '\\includegraphics[]{frog.JPG}')
})
it('opens autocomplete on begin environment', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [
{
_id: 'test-folder-id',
name: 'test-folder',
docs: [
{
_id: 'test-doc-in-folder',
name: 'example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-folder',
name: 'example.png',
hash: '42',
},
],
folders: [],
},
],
fileRefs: [
{
_id: 'test-image-file',
name: 'frog.jpg',
hash: '43',
},
],
},
]
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// ---- Basic autocomplete of environments
cy.get('@line').type('\\begin{itemi')
cy.findAllByRole('option').contains('\\begin{itemize}').click()
cy.get('.cm-line').eq(16).contains('\\begin{itemize}')
cy.get('.cm-line').eq(17).contains('\\item')
cy.get('.cm-line').eq(18).contains('\\end{itemize}')
// ---- Autocomplete on a malformed `\begin{`
// first, ensure that the "abcdef" environment is present in the doc
cy.get('.cm-line')
.eq(20)
.type('\\begin{{}abcdef}{Enter}{Enter}\\end{{}abcdef}{Enter}{Enter}')
cy.get('.cm-line').eq(24).as('line')
cy.get('@line').type('\\begin{abcdef')
cy.findAllByRole('option').contains('\\begin{abcdef}').click()
cy.get('.cm-line').eq(24).contains('\\begin{abcdef}')
cy.get('.cm-line').eq(26).contains('\\end{abcdef}')
// ---- Autocomplete starting from end of `\begin`
cy.get('.cm-line').eq(22).type('{Enter}{Enter}{Enter}')
cy.get('.cm-line').eq(24).as('line')
cy.get('@line').type('\\begin {leftArrow}{leftArrow}')
cy.get('@line').type('{ctrl} ')
cy.findAllByRole('option').contains('\\begin{align}').click()
cy.get('.cm-line').eq(24).contains('\\begin{align}')
cy.get('.cm-line').eq(26).contains('\\end{align}')
// ---- Start typing a begin command
cy.get('.cm-line').eq(28).as('line')
cy.get('@line').click()
cy.get('@line').type('\\begin{{}ab')
cy.findAllByRole('option').as('options')
cy.get('@options').should('have.length', 5)
// ---- The environment being typed should appear in the list
cy.get('@options').contains('\\begin{ab}')
// ---- A new environment used elsewhere in the doc should appear next
cy.get('@options')
.eq(0)
.invoke('text')
.should('match', /^\\begin\{abcdef}/)
// ---- The built-in environments should appear at the top of the list
cy.get('@options')
.eq(1)
.invoke('text')
.should('match', /^\\begin\{abstract}/)
})
it('opens autocomplete using metadata for usepackage parameter', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const metadata = {
commands: [],
labels: new Set<string>(),
packageNames: new Set(['foo']),
}
const MetadataProvider: FC = ({ children }) => {
return (
<MetadataContext.Provider value={metadata}>
{children}
</MetadataContext.Provider>
)
}
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
rootFolder={rootFolder as any}
providers={{ MetadataProvider }}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a usepackage command
cy.get('@line').type('\\usepackage')
// autocomplete open
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option').eq(0).should('contain.text', '\\usepackage{}')
cy.findAllByRole('option').eq(1).should('contain.text', '\\usepackage[]{}')
// the start of a package name from the metadata
cy.get('@line').type('{fo')
// autocomplete open
cy.findAllByRole('listbox')
.should('have.length', 1)
.type('{downArrow}{downArrow}{Enter}')
cy.contains('\\usepackage{foo}')
})
it('opens autocomplete using metadata for cite parameter', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
const ReferencesProvider: FC = ({ children }) => {
return (
<ReferencesContext.Provider
value={{
referenceKeys: new Set(['ref-1', 'ref-2', 'ref-3']),
indexAllReferences: cy.stub(),
}}
>
{children}
</ReferencesContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ ReferencesProvider }}
rootFolder={rootFolder as any}
>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a cite command with no opening brace
cy.get('@line').type('\\cite')
// select completion
cy.findAllByRole('listbox').contains('\\cite{}').click()
cy.get('@line').contains('\\cite{}')
// autocomplete open again
cy.findAllByRole('listbox').contains('ref-2').click()
cy.get('@line').contains('\\cite{ref-2}')
// start typing another reference
cy.get('@line').type('{leftArrow}, re')
// autocomplete open again
cy.findAllByRole('listbox').contains('ref-3').click()
cy.get('@line').contains('\\cite{ref-2, ref-3}')
})
it('autocomplete stops after space after command', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
cy.contains('\\section{Results}')
// no autocomplete
cy.findAllByRole('listbox').should('have.length', 0)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// single backslash
cy.get('@line').type('\\')
// autocomplete
cy.findAllByRole('listbox').should('have.length', 1)
// start a command
cy.get('@line').type('ite')
// offers completion for item
cy.contains(/\\item.*cmd/).click()
cy.get('@line').type('{Enter}{Enter}\\item ')
cy.contains('\\begin{itemize').should('not.exist')
})
it('autocomplete does not remove closing brackets in commands with multiple braces {}{}', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\frac')
// select completion
cy.findAllByRole('listbox').contains('\\frac{}{}').click()
cy.get('@line').type('\\textbf')
// select completion
cy.findAllByRole('listbox').contains('\\textbf{}').click()
cy.get('@line').should('contain.text', '\\frac{\\textbf{}}{}')
// go to new line
cy.get('@line').click()
cy.get('@line').type('{enter}')
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
cy.get('@line').type('\\frac')
// select completion
cy.findAllByRole('listbox').contains('\\frac{}{}').click()
cy.get('@line').type('\\partial')
// select completion
cy.findAllByRole('listbox').contains('\\partial').click()
cy.get('@line').should('contain.text', '\\frac{\\partial}{}')
})
it('autocomplete does not remove paired closing brackets in nested commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type some commands
// note: '{{}' is a single opening brace
cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}')
// put the cursor inside the middle pair of braces
cy.get('@line').type('{leftArrow}{leftArrow}')
// start a command
cy.get('@line').type('\\sqrt')
// select completion
cy.findAllByRole('listbox').contains('\\sqrt{}').click()
// assert that the existing closing brace hasn't been removed
cy.get('@line').should(
'have.text',
'\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}'
)
})
it('autocomplete does remove unpaired closing brackets in nested commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type some commands
// note: '{{}' is a single opening brace
cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}}')
// put the cursor inside the middle pair of braces
cy.get('@line').type('{leftArrow}{leftArrow}{leftArrow}')
// start a command
cy.get('@line').type('\\sqrt')
// select completion
cy.findAllByRole('listbox').contains('\\sqrt{}').click()
// assert that the existing closing brace hasn't been removed
cy.get('@line').should(
'have.text',
'\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}'
)
})
it('displays completions for existing commands with multiple parameters', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('\\foo[bar]{{}baz}{{}zap}')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('\\foo')
// select the new completion
cy.findAllByRole('listbox').contains('\\foo[]{}{}').click()
// fill in the optional parameter
cy.get('@line').type('bar')
cy.get('@editor').contains('\\foo[bar]{}{}')
})
it('displays completions for existing commands in math mode', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('$\\somemathcommand$')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('hello \\somema')
// select the new completion
cy.findAllByRole('listbox').contains('\\somemathcommand').click()
cy.get('@editor').contains('hello \\somemathcommand')
})
it('displays completions for nested existing commands', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// a new command, then the start of the command on a new blank line
cy.get('@line').type('\\newcommand{{}\\foo}[1]{{}#1}')
// enter, to create a new line
cy.get('@editor').trigger('keydown', { key: 'Enter' })
// put the cursor on the new line to type in
cy.get('.cm-line').eq(17).as('line')
cy.get('@line').click()
// the start of the command
cy.get('@line').type('\\fo')
// select the new completion
cy.findAllByRole('listbox')
.contains(/\\foo{}\s*cmd/)
.click()
cy.get('@line').contains('\\foo')
})
it('displays unique completions for commands', function () {
const scope = mockScope()
const metadata = {
commands: [
{
caption: '\\label{}', // label{} is also included in top-hundred-snippets
meta: 'amsmath-cmd',
score: 1,
snippet: '\\label{$1}',
},
],
labels: new Set<string>(),
packageNames: new Set<string>('amsmath'),
}
const MetadataProvider: FC = ({ children }) => {
return (
<MetadataContext.Provider value={metadata}>
{children}
</MetadataContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ MetadataProvider }}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-editor').as('editor')
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// start typing a command
cy.get('@line').type('\\label')
cy.findAllByRole('option').contains('\\label{}').should('have.length', 1)
})
it('displays symbol completions in autocomplete when the feature is enabled', function () {
const scope = mockScope()
window.metaAttributesCache.set('ol-showSymbolPalette', true)
const user = {
id: '123abd',
email: 'testuser@example.com',
}
cy.mount(
<TestContainer>
<EditorProviders user={user} scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type the name of a symbol
cy.get('@line').type(' \\alpha')
cy.findAllByRole('listbox').should('have.length', 1)
// the symbol completion should exist
cy.findAllByRole('option', {
name: /^\\alpha\s+𝛼\s+Greek$/,
}).should('have.length', 1)
})
it('does not display symbol completions in autocomplete when the feature is disabled', function () {
const scope = mockScope()
window.metaAttributesCache.set('ol-showSymbolPalette', false)
const user = {
id: '123abd',
email: 'testuser@example.com',
}
cy.mount(
<TestContainer>
<EditorProviders user={user} scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// type the name of a symbol
cy.get('@line').type(' \\alpha')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option', {
name: /^\\alpha\s+𝛼\s+Greek$/,
}).should('have.length', 0)
})
it('displays environment completion when typing up to closing brace', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// Type \begin{itemize}.
// Note: '{{}' is a single opening brace
cy.get('@line').type('\\begin{{}itemize}', {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('displays environment completion when typing inside \\begin{}', function () {
const scope = mockScope(mockDocContent('\\begin{}'))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line above target line
cy.get('.cm-line').eq(20).as('line')
cy.get('@line').click()
// Move to the position between the braces then type 'itemize'
cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize`, {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('displays environment completion when typing after \\begin{', function () {
const scope = mockScope(mockDocContent('\\begin{'))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line above target line
cy.get('.cm-line').eq(20).as('line')
cy.get('@line').click()
// Move to the position after the opening brace then type 'itemize}'
cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize}`, {
delay: 100,
})
cy.findAllByRole('listbox').should('have.length', 1)
})
it('removes .tex but not .txt file extension from \\include and \\input', function () {
const rootFolder: Folder[] = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
{
_id: 'test-include-tex-doc',
name: 'example.tex',
},
{
_id: 'test-include-txt',
name: 'sometext.txt',
},
],
folders: [],
fileRefs: [],
},
]
const scope = mockScope()
scope.project.rootFolder = rootFolder
cy.mount(
<TestContainer>
<EditorProviders scope={scope} rootFolder={rootFolder as any}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Put the cursor on a blank line and type
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\include{e', { delay: 100 })
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().contains('\\include{example')
activeEditorLine().type('}{Enter}')
activeEditorLine().type('\\include{s', { delay: 100 })
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\include{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inclu', { delay: 100 })
cy.contains('\\include{}').click()
cy.contains('example.tex').click()
activeEditorLine().should('have.text', '\\include{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inclu', { delay: 100 })
cy.findAllByRole('option').contains('\\include{}').click()
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\include{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().click().as('line')
activeEditorLine().type('\\input{e', { delay: 100 })
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().should('have.text', '\\input{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().click().as('line')
activeEditorLine().type('\\input{s', { delay: 100 })
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\input{sometext.txt}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inpu', { delay: 100 })
cy.findAllByRole('option').contains('\\input{}').click()
cy.findAllByRole('option').contains('example.tex').click()
activeEditorLine().should('have.text', '\\input{example}')
activeEditorLine().type('{Enter}')
activeEditorLine().type('\\inpu', { delay: 100 })
cy.findAllByRole('option').contains('\\input{}').click()
cy.findAllByRole('option').contains('sometext.txt').click()
activeEditorLine().should('have.text', '\\input{sometext.txt}')
})
it('excludes the current command from completions', function () {
const scope = mockScope(mockDocContent(''))
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(21).type('\\fff \\ff')
cy.findAllByRole('listbox').should('have.length', 1)
cy.findAllByRole('option').contains('\\fff').click()
cy.get('.cm-line').eq(21).should('have.text', '\\fff \\fff')
})
})

View File

@@ -0,0 +1,206 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
describe('close brackets', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(20).as('active-line')
cy.get('@active-line').click()
})
describe('unprefixed characters', function () {
it('auto-closes a curly bracket', function () {
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '{}')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('auto-closes a square bracket', function () {
cy.get('@active-line').type('[')
cy.get('@active-line').should('have.text', '[]')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('does not auto-close a round bracket', function () {
cy.get('@active-line').type('(')
cy.get('@active-line').should('have.text', '(')
})
it('auto-closes a dollar sign', function () {
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '$$')
cy.get('@active-line').type('{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('auto-closes another dollar sign', function () {
cy.get('@active-line').type('$$')
cy.get('@active-line').should('have.text', '$$$$')
cy.get('@active-line').type('{backspace}{backspace}')
cy.get('@active-line').should('have.text', '')
})
it('avoids creating an odd number of adjacent dollar signs', function () {
cy.get('@active-line').type('$2')
cy.get('@active-line').should('have.text', '$2$')
cy.get('@active-line').type('{leftArrow}$')
cy.get('@active-line').should('have.text', '$$2$')
})
})
describe('prefixed characters', function () {
it('auto-closes a backslash-prefixed round bracket', function () {
cy.get('@active-line').type('\\(')
cy.get('@active-line').should('have.text', '\\(\\)')
})
it('auto-closes a backslash-prefixed square bracket', function () {
cy.get('@active-line').type('\\[')
cy.get('@active-line').should('have.text', '\\[\\]')
})
it('does not auto-close a backslash-prefixed curly bracket', function () {
cy.get('@active-line').type('\\{{}')
cy.get('@active-line').should('have.text', '\\{')
})
it('does not auto-close a backslash-prefixed dollar sign', function () {
cy.get('@active-line').type('\\$')
cy.get('@active-line').should('have.text', '\\$')
})
})
describe('double-prefixed characters', function () {
it('auto-closes a double-backslash-prefixed square bracket with a square bracket', function () {
cy.get('@active-line').type('\\\\[')
cy.get('@active-line').should('have.text', '\\\\[]')
})
it('auto-closes a double-backslash-prefixed curly bracket with a curly bracket', function () {
cy.get('@active-line').type('\\\\{')
cy.get('@active-line').should('have.text', '\\\\{}')
})
it('auto-closes a double-backslash-prefixed dollar sign with a dollar sign', function () {
cy.get('@active-line').type('\\\\$')
cy.get('@active-line').should('have.text', '\\\\$$')
})
it('does not auto-close a double-backslash-prefixed round bracket', function () {
cy.get('@active-line').type('\\\\(')
cy.get('@active-line').should('have.text', '\\\\(')
})
})
describe('adjacent characters', function () {
it('does auto-close a dollar sign before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$$:2')
})
it('does auto-close a dollar sign after punctuation', function () {
cy.get('@active-line').type('2:')
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '2:$$')
})
it('does not auto-close a dollar sign before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}$')
cy.get('@active-line').should('have.text', '$2')
})
it('does not auto-close a dollar sign after text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('$')
cy.get('@active-line').should('have.text', '2$')
})
it('does not auto-close a dollar sign before a command', function () {
cy.get('@active-line').type('\\nu')
cy.get('@active-line').type('{leftArrow}{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$\\nu')
})
it('does auto-close a dollar sign before a newline', function () {
cy.get('@active-line').type('\\\\')
cy.get('@active-line').type('{leftArrow}{leftArrow}$')
cy.get('@active-line').should('have.text', '$$\\\\')
})
it('does auto-close a curly bracket before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}{{}')
cy.get('@active-line').should('have.text', '{}:2')
})
it('does auto-close a curly bracket after punctuation', function () {
cy.get('@active-line').type('2:')
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '2:{}')
})
it('does not auto-close a curly bracket before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}{{}')
cy.get('@active-line').should('have.text', '{2')
})
it('does auto-close a curly bracket after text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{{}')
cy.get('@active-line').should('have.text', '2{}')
})
it('does auto-close $$ before punctuation', function () {
cy.get('@active-line').type(':2')
cy.get('@active-line').type('{leftArrow}{leftArrow}$$')
cy.get('@active-line').should('have.text', '$$$$:2')
})
it('does not auto-close $$ before text', function () {
cy.get('@active-line').type('2')
cy.get('@active-line').type('{leftArrow}$$')
cy.get('@active-line').should('have.text', '$$2')
})
})
describe('closed brackets', function () {
it('does type over a closing dollar sign', function () {
cy.get('@active-line').type('$2$')
cy.get('@active-line').should('have.text', '$2$')
})
it('does type over two closing dollar signs', function () {
cy.get('@active-line').type('$$2$$')
cy.get('@active-line').should('have.text', '$$2$$')
})
it('does type over a closing curly bracket', function () {
cy.get('@active-line').type('{{}2}')
cy.get('@active-line').should('have.text', '{2}')
})
it('does type over a closing square bracket', function () {
cy.get('@active-line').type('[2]')
cy.get('@active-line').should('have.text', '[2]')
})
})
})

View File

@@ -0,0 +1,118 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
describe('Cursor and active line highlight', function () {
const content = `line 1
${'long line '.repeat(200)}`
function assertIsFullLineHeight($item: JQuery<HTMLElement>) {
cy.get('@line').then($line => {
expect(Math.round($item.outerHeight()!)).to.equal(
Math.round($line.outerHeight()!)
)
})
}
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope(content)
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
})
it('has cursor', function () {
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-cursor').as('cursor')
cy.get('.cm-cursor').then(assertIsFullLineHeight)
})
it('has cursor on empty line whose height is the same as the line', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-cursor').as('cursor')
cy.get('@cursor').then(assertIsFullLineHeight)
})
it('has cursor on non-empty line whose height is the same as the line', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('wombat')
cy.get('.cm-cursor').as('cursor')
cy.get('@cursor').then(assertIsFullLineHeight)
})
it('puts cursor in the correct place inside brackets', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('[{Enter}')
// Get the line inside the bracket
cy.get('.cm-line').eq(2).as('line')
// Check that the middle of the cursor is within the line boundaries
cy.get('.cm-cursor').then($cursor => {
cy.get('@line').then($line => {
const cursorCentreY = $cursor.offset()!.top + $cursor.outerHeight()! / 2
const lineTop = $line.offset()!.top
const lineBottom = lineTop + $line.outerHeight()!
expect(cursorCentreY).to.be.within(lineTop, lineBottom)
})
})
})
it('has active line highlight line decoration of same height as line when there is no selection and line does not wrap', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('.cm-content .cm-activeLine').as('highlight')
cy.get('.ol-cm-activeLineLayer .cm-activeLine').should('not.exist')
cy.get('@highlight').then(assertIsFullLineHeight)
})
it('has active line highlight layer decoration of same height as non-wrapped line when there is no selection and line wraps', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(2).as('line')
cy.get('@line').click()
cy.get('.ol-cm-activeLineLayer .cm-activeLine').as('highlight')
cy.get('.cm-content .cm-activeLine').should('not.exist')
cy.get('.cm-line').eq(1).as('line')
cy.get('@highlight').then(assertIsFullLineHeight)
})
it('has no active line highlight when there is a selection', function () {
// Put the cursor on a blank line
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type(isMac ? '{cmd}A' : '{ctrl}A')
cy.get('.cm-activeLine').should('not.exist')
})
})

View File

@@ -0,0 +1,599 @@
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { EditorProviders } from '../../../helpers/editor-providers'
import { mockScope, rootFolderId } from '../helpers/mock-scope'
import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import getMeta from '@/utils/meta'
const clickToolbarButton = (text: string) => {
cy.findByLabelText(text).click()
cy.findByLabelText(text).trigger('mouseout')
}
const chooseFileFromComputer = () => {
cy.get('@file-input').selectFile(
{
fileName: 'frog.jpg',
contents: Cypress.Buffer.from('image-data'),
mimeType: 'image/jpg',
},
{
force: true,
}
)
}
const matchUrl = (urlToMatch: RegExp | string) =>
Cypress.sinon.match(req => {
if (!req || typeof req.url !== 'string') {
return false
}
if (typeof urlToMatch === 'string') {
return req.url.endsWith(urlToMatch)
}
return Boolean(req.url.match(urlToMatch))
})
describe('<FigureModal />', function () {
// TODO: rewrite these tests to be in source mode when toolbar is added there
// TODO: Write tests for width toggle, when we can match on source code
function mount() {
const content = ''
const scope = mockScope(content)
scope.editor.showVisual = true
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.returns({ url: 'frog.jpg', extension: 'jpg' }),
}}
>
{children}
</FileTreePathContext.Provider>
)
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
}
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptEvents()
cy.interceptMetadata()
mount()
})
describe('Upload from computer source', function () {
beforeEach(function () {
cy.interceptFileUpload()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('Upload from computer').click()
})
cy.findByLabelText('Uppy Dashboard')
.get('.uppy-Dashboard-input:first')
.as('file-input')
})
it('Shows file name and size when selecting file', function () {
chooseFileFromComputer()
cy.findByLabelText('File name').should('have.text', 'frog.jpg')
cy.findByLabelText('File size').should('have.text', '10 B')
})
it('Uploads file when clicking insert', function () {
chooseFileFromComputer()
cy.get('@uploadRequest').should('not.have.been.called')
cy.findByText('Insert figure').click()
cy.get('@uploadRequest').should(
'have.been.calledWith',
matchUrl(`/project/test-project/upload?folder_id=${rootFolderId}`)
)
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
chooseFileFromComputer()
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
})
describe('Upload from project files source', function () {
beforeEach(function () {
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From project files').click()
})
})
it('Lists files from project', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox')
.children()
.should('have.length', 2)
.should('contain.text', 'frog.jpg')
.should('contain.text', 'unicorn.png')
.should('contain.text', 'figures/')
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Inserts file when pressing insert button', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
})
describe('From another project source', function () {
beforeEach(function () {
cy.interceptProjectListing()
cy.interceptCompile()
cy.interceptLinkedFile()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
})
cy.findByRole('textbox', { name: 'Project' }).as('project-dropdown')
cy.findByRole('textbox', { name: 'Image file' }).as('file-dropdown')
})
it('List projects and files in projects', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@file-dropdown').should('be.disabled')
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findAllByRole('option').should('have.length', 2)
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').should('be.enabled')
cy.get('@file-dropdown').click()
cy.findByRole('listbox').as('file-select')
cy.get('@file-select').children().should('have.length', 2)
cy.get('@file-select').should('contain.text', 'frog.jpg')
cy.get('@file-select').should('contain.text', 'figures/unicorn.png')
cy.get('@file-select').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Enables insert button when choosing file', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'frog.jpg' }).click()
})
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Creates linked file when pressing insert', function () {
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.get('@file-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByText('frog.jpg').click()
})
cy.findByText('Insert figure').click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'project_file',
data: {
source_entity_path: '/frog.jpg',
source_project_id: 'fake-project-1',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Creates linked output file when pressing insert', function () {
cy.get('@project-dropdown').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
cy.findByRole('button', { name: 'select from output files' }).click()
cy.findByRole('textbox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
cy.findByText('Insert figure').click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'project_output_file',
data: {
source_output_file_path: 'output.pdf',
source_project_id: 'fake-project-1',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
})
describe('Feature flags', function () {
describe('with hasLinkUrlFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: true,
hasLinkedProjectOutputFileFeature: true,
hasLinkUrlFeature: false,
})
mount()
clickToolbarButton('Insert Figure')
})
it('should not have import from url option', function () {
cy.findByRole('menu').within(() => {
cy.findByText('From URL').should('not.exist')
})
})
})
describe('with hasLinkedProjectFileFeature=false and hasLinkedProjectOutputFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: false,
hasLinkedProjectOutputFileFeature: false,
hasLinkUrlFeature: true,
})
mount()
clickToolbarButton('Insert Figure')
})
it('should not have import from project file option', function () {
cy.findByRole('menu').within(() => {
cy.findByText('From another project').should('not.exist')
})
})
})
function setupFromAnotherProject() {
mount()
cy.interceptProjectListing()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From another project').click()
})
cy.findByRole('textbox', { name: 'Project' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'My first project' }).click()
})
}
function expectNoOutputSwitch() {
it('should hide output switch', function () {
cy.findByText('select from output files').should('not.exist')
cy.findByText('select from source files').should('not.exist')
})
}
describe('with hasLinkedProjectFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: false,
hasLinkedProjectOutputFileFeature: true,
hasLinkUrlFeature: true,
})
cy.interceptCompile()
setupFromAnotherProject()
})
expectNoOutputSwitch()
it('should show output file selector', function () {
cy.findByRole('textbox', { name: 'Output file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'output.pdf' }).click()
})
})
})
describe('with hasLinkedProjectOutputFileFeature=false', function () {
beforeEach(function () {
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkedProjectFileFeature: true,
hasLinkedProjectOutputFileFeature: false,
hasLinkUrlFeature: true,
})
setupFromAnotherProject()
})
expectNoOutputSwitch()
it('should show source file selector', function () {
cy.findByRole('textbox', { name: 'Image file' }).click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', { name: 'frog.jpg' }).click()
})
})
})
})
describe('From URL source', function () {
beforeEach(function () {
cy.interceptLinkedFile()
clickToolbarButton('Insert Figure')
cy.findByRole('menu').within(() => {
cy.findByText('From URL').click()
})
cy.findByLabelText('File name in this project').as(
'relocated-file-name-input'
)
cy.findByLabelText('Image URL').as('image-url-input')
cy.findByRole('checkbox', { name: 'Include label' }).as(
'include-label-checkbox'
)
cy.findByRole('checkbox', { name: 'Include caption' }).as(
'include-caption-checkbox'
)
})
it('Auto fills name based on url', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@relocated-file-name-input').should('have.value', 'frog.jpg')
cy.get('@relocated-file-name-input').type('pig')
cy.get('@relocated-file-name-input').should('have.value', 'pig.jpg')
})
it('Enables insert button when name and url is available', function () {
cy.findByRole('button', { name: 'Insert figure' }).should('be.disabled')
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).should('be.enabled')
})
it('Adds linked file when pressing insert', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{Enter Caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Selects the caption when the figure is inserted with a caption', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-selectionLayer .cm-selectionBackground').should(
'have.length',
1
)
// If caption is selected then typing will replace the whole caption
cy.focused().type('My caption')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\caption{My caption} 🏷fig:enter-label\\end{figure}'
)
})
it('Selects the label when the figure is inserted without a caption', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@include-caption-checkbox').uncheck()
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-selectionLayer .cm-selectionBackground').should(
'have.length',
1
)
// If label is selected then typing will replace the whole label
cy.focused().type('fig:my-label')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering \\label{fig:my-label}\\end{figure}'
)
})
it('Places the cursor after the figure if it is inserted without a caption or a label', function () {
cy.get('@image-url-input').type('https://my-fake-website.com/frog.jpg')
cy.get('@include-caption-checkbox').uncheck()
cy.get('@include-label-checkbox').uncheck()
cy.findByRole('button', { name: 'Insert figure' }).click()
cy.get('@linked-file-request').should('have.been.calledWithMatch', {
body: {
provider: 'url',
data: {
url: 'https://my-fake-website.com/frog.jpg',
},
},
})
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering\\end{figure}'
)
cy.focused().type('Some more text')
cy.get('.cm-content').should(
'have.text',
'\\begin{figure} \\centering\\end{figure}Some more text'
)
})
})
describe('Editing existing figure', function () {
it('Parses existing label and caption', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.5\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include caption' }).should(
'be.checked'
)
cy.findByRole('checkbox', { name: 'Include label' }).should('be.checked')
})
it('Parses existing width', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.get('[value="0.75"]').should('be.checked')
})
it('Removes existing label when unchecked', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include label' }).click()
cy.findByRole('checkbox', { name: 'Include label' }).should(
'not.be.checked'
)
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure}\\centering\\end{figure}'
)
})
it('Removes existing caption when unchecked', function () {
cy.get('.cm-content').type(
`\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('checkbox', { name: 'Include caption' }).click()
cy.findByRole('checkbox', { name: 'Include caption' }).should(
'not.be.checked'
)
cy.findByText('Done').click()
cy.get('.cm-content').should(
'have.text',
'\\begin{figure}\\centering🏷fig:my-label\\end{figure}'
)
})
it('Preserves other content when removing figure', function () {
cy.get('.cm-content').type(
`text above
\\begin{{}figure}
\\centering
\\includegraphics[width=0.75\\linewidth]{{}frog.jpg}
\\caption{{}My caption}
\\label{{}fig:my-label}
\\end{{}figure}
text below`,
{ delay: 0 }
)
cy.get('[aria-label="Edit figure"]').click()
cy.findByRole('button', { name: 'Remove or replace figure' }).click()
cy.findByText('Delete figure').click()
cy.get('.cm-content').should('have.text', 'text abovetext below')
})
it('Opens figure modal on pasting image', function () {
cy.fixture<Uint8Array>('images/gradient.png').then(gradientBuffer => {
const gradientFile = new File([gradientBuffer], 'gradient.png', {
type: 'image/png',
})
const clipboardData = new DataTransfer()
clipboardData.items.add(gradientFile)
cy.wrap(clipboardData.files).should('have.length', 1)
cy.get('.cm-content').trigger('paste', { clipboardData })
cy.findByText('Upload from computer').should('be.visible')
cy.findByLabelText('File name in this project').should(
'have.value',
'gradient.png'
)
})
})
// TODO: Add tests for replacing image when we can match on image path
// TODO: Add tests for changing image size when we can match on figure width
})
})

View File

@@ -0,0 +1,73 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> fundamentals', function () {
const content = `
test
`
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
const scope = mockScope(content)
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(0).as('first-line')
cy.get('.cm-line').eq(1).as('line')
cy.get('.cm-line').eq(2).as('empty-line')
cy.get('@line').click()
})
it('deletes with backspace', function () {
cy.get('@line').type('{backspace}')
cy.get('@line').should('have.text', 'tes')
})
it('moves with arrow keys', function () {
cy.get('@line').type('{leftArrow}1')
cy.get('@line').should('have.text', 'tes1t')
cy.get('@line').type('{rightArrow}2')
cy.get('@line').should('have.text', 'tes1t2')
cy.get('@line').type('{downArrow}3')
cy.get('@line').type('{upArrow}{upArrow}4')
cy.get('@empty-line').should('have.text', '3')
cy.get('@first-line').should('have.text', '4')
})
it('deletes with delete', function () {
cy.get('@line').type('{leftArrow}{del}')
cy.get('@line').should('have.text', 'tes')
})
it('types characters', function () {
cy.get('@empty-line').type('hello codemirror!')
cy.get('@empty-line').should('have.text', 'hello codemirror!')
})
it('replaces selections', function () {
cy.get('@line').type('{shift}{leftArrow}{leftArrow}{leftArrow}')
cy.get('@line').type('abby cat')
cy.get('@line').should('have.text', 'tabby cat')
})
it('inserts LaTeX commands', function () {
cy.get('@empty-line').type('\\cmd[opt]{{}arg}')
cy.get('@empty-line').should('have.text', '\\cmd[opt]{arg}')
})
it('allows line-breaks', function () {
cy.get('.cm-content').find('.cm-line').should('have.length', 3)
cy.get('@empty-line').type('{enter}{enter}')
cy.get('.cm-content').find('.cm-line').should('have.length', 5)
})
})

View File

@@ -0,0 +1,280 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { metaKey } from '../helpers/meta-key'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
const CHARACTERS =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\\0123456789'
describe('keyboard shortcuts', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('comment line with {meta+/}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+/}`)
cy.get('@line').should('have.text', '% text')
cy.get('@line').type(`{${metaKey}+/}`)
cy.get('@line').should('have.text', 'text')
})
it('comment line with {ctrl+#}', function () {
cy.get('@line').type('text')
cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true })
cy.get('@line').should('have.text', '% text')
cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true })
cy.get('@line').should('have.text', 'text')
})
it('undo line with {meta+z}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`)
cy.get('@line').should('have.text', '')
})
it('redo line with {meta+shift+z}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`) // undo
cy.get('@line').type(`{${metaKey}+shift+z}`) // redo
cy.get('@line').should('have.text', 'text')
})
it('redo line with {meta+y}', function () {
cy.get('@line').type('text')
cy.get('@line').type(`{${metaKey}+z}`) // undo
cy.get('@line').type(`{${metaKey}+y}`) // redo
cy.get('@line').should('have.text', 'text')
})
it('delete line with {meta+d}', function () {
cy.get('.cm-line').then($lines => {
const linesCount = $lines.length
cy.get('@line').type(`{${metaKey}+d}`)
cy.get('.cm-line').should('have.length', linesCount - 1)
})
})
it('indent line with {tab}', function () {
cy.get('@line').trigger('keydown', { key: 'Tab' })
cy.get('@line').should('have.text', ' ')
})
it('unindent line with {shift+tab}', function () {
cy.get('@line').trigger('keydown', { key: 'Tab' }) // indent
cy.get('@line').trigger('keydown', { key: 'Tab', shiftKey: true }) // unindent
cy.get('@line').should('have.text', '')
})
it('uppercase selection with {ctrl+u}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type('{ctrl+u}')
cy.get('@line').should('have.text', 'A')
})
it('lowercase selection with {ctrl+shift+u}', function () {
if (navigator.platform.startsWith('Linux')) {
// Skip test as {ctrl+shift+u} is bound elsewhere in some Linux systems
// eslint-disable-next-line mocha/no-skipped-tests
this.skip()
}
cy.get('@line').type('A')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type('{ctrl+shift+u}') // TODO: ctrl+shift+u is a system shortcut so this fails in CI
cy.get('@line').should('have.text', 'a')
})
it('wrap selection with "\\textbf{}" by using {meta+b}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type(`{${metaKey}+b}`)
cy.get('@line').should('have.text', '\\textbf{a}')
})
it('wrap selection with "\\textit{}" by using {meta+i}', function () {
cy.get('@line').type('a')
cy.get('@line').type('{shift+leftArrow}') // select text
cy.get('@line').type(`{${metaKey}+i}`)
cy.get('@line').should('have.text', '\\textit{a}')
})
})
describe('emacs keybindings', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
const shortDoc = `
\\documentclass{article}
\\begin{document}
contentLine1
contentLine2
contentLine3
\\end{document}`
const scope = mockScope(shortDoc)
const userSettings = { mode: 'emacs' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('emulates search behaviour', function () {
activeEditorLine().should('have.text', '\\documentclass{article}')
// Search should be closed
cy.findByRole('search').should('have.length', 0)
// Invoke C-s
cy.get('@line').type('{ctrl}s')
// Search should now be open
cy.findByRole('search').should('have.length', 1)
cy.findByRole('textbox', { name: 'Find' }).as('search-input')
// Write a search query
cy.get('@search-input').should('have.focus').type('contentLine')
cy.contains(`1 of 3`)
// Should assert that activeEditorLine.index() === 21, but activeEditorLine
// only works if editor is focused, not the search box.
// Repeated C-s should go to next match
cy.get('@search-input').type('{ctrl}s')
cy.contains(`2 of 3`)
// Should assert that activeEditorLine.index() === 22, but activeEditorLine
// only works if editor is focused, not the search box.
// C-g should close the search
cy.get('@search-input').type('{ctrl}g')
cy.findByRole('search').should('have.length', 0)
// Cursor should be back to where the search originated from
activeEditorLine().should('have.text', '\\documentclass{article}')
// Invoke C-r
cy.get('@line').type('{ctrl}r')
// Search should now be open at first match
cy.findByRole('search').should('have.length', 1)
cy.contains(`0 of 3`)
// Repeated C-r should go to previous match
cy.get('@search-input').type('{ctrl}r')
cy.contains(`3 of 3`)
// Close search panel to clear global variable
cy.get('@search-input').type('{ctrl}g')
cy.findByRole('search').should('have.length', 0)
})
it('toggle comments with M-;', function () {
cy.get('@line').should('have.text', '\\documentclass{article}')
cy.get('@line').type('{alt};')
cy.get('@line').should('have.text', '% \\documentclass{article}')
})
it('should jump between start and end with M-S-, and M-S-.', function () {
activeEditorLine().should('have.text', '\\documentclass{article}')
activeEditorLine().type('{alt}{shift},')
activeEditorLine().should('have.text', '')
activeEditorLine().type('{alt}{shift}.')
activeEditorLine().should('have.text', '\\end{document}')
})
it('can enter characters', function () {
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('@line').type(CHARACTERS)
cy.get('@line').should('have.text', CHARACTERS)
})
})
describe('vim keybindings', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
// Make a short doc that will fit entirely into the dom tree, so that
// index() corresponds to line number - 1
const shortDoc = `
\\documentclass{article}
\\begin{document}
contentLine1
contentLine2
contentLine3
\\end{document}
`
const scope = mockScope(shortDoc)
const userSettings = { mode: 'vim' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('.cm-editor').as('editor')
})
it('can enter characters', function () {
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').scrollIntoView()
cy.get('@line').click()
cy.get('@line').type(`i${CHARACTERS}{esc}`)
cy.get('@line').should('have.text', CHARACTERS)
})
it('can move around in normal mode', function () {
// Move cursor up
cy.get('@line').type('k')
activeEditorLine().should('have.text', '')
// Move cursor down
cy.get('@line').type('j')
activeEditorLine().should('have.text', '\\begin{document}')
// Move the cursor left, insert 1, move it right, insert a 2
cy.get('@line').type('hi1{esc}la2{esc}')
cy.get('@line').should('have.text', '\\documentclass{article1}2')
})
})

View File

@@ -0,0 +1,157 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
import forEach from 'mocha-each'
import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
const languages = [
{ code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
{ code: 'an', dic: 'an_ES', name: 'Aragonese' },
{ code: 'ar', dic: 'ar', name: 'Arabic' },
{ code: 'be_BY', dic: 'be_BY', name: 'Belarusian' },
{ code: 'bg', dic: 'bg_BG', name: 'Bulgarian' },
{ code: 'bn_BD', dic: 'bn_BD', name: 'Bengali' },
{ code: 'bo', dic: 'bo', name: 'Tibetan' },
{ code: 'br', dic: 'br_FR', name: 'Breton' },
{ code: 'bs_BA', dic: 'bs_BA', name: 'Bosnian' },
{ code: 'ca', dic: 'ca', name: 'Catalan' },
{ code: 'cs', dic: 'cs_CZ', name: 'Czech' },
{ code: 'de', dic: 'de_DE', name: 'German' },
{ code: 'de_AT', dic: 'de_AT', name: 'German (Austria)' },
{ code: 'de_CH', dic: 'de_CH', name: 'German (Switzerland)' },
{ code: 'dz', dic: 'dz', name: 'Dzongkha' },
{ code: 'el', dic: 'el_GR', name: 'Greek' },
{ code: 'en_AU', dic: 'en_AU', name: 'English (Australian)' },
{ code: 'en_CA', dic: 'en_CA', name: 'English (Canadian)' },
{ code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
{ code: 'en_US', dic: 'en_US', name: 'English (American)' },
{ code: 'en_ZA', dic: 'en_ZA', name: 'English (South African)' },
{ code: 'eo', dic: 'eo', name: 'Esperanto' },
{ code: 'es', dic: 'es_ES', name: 'Spanish' },
{ code: 'et', dic: 'et_EE', name: 'Estonian' },
{ code: 'eu', dic: 'eu', name: 'Basque' },
{ code: 'fa', dic: 'fa_IR', name: 'Persian' },
{ code: 'fo', dic: 'fo', name: 'Faroese' },
{ code: 'fr', dic: 'fr', name: 'French' },
{ code: 'ga', dic: 'ga_IE', name: 'Irish' },
{ code: 'gd_GB', dic: 'gd_GB', name: 'Scottish Gaelic' },
{ code: 'gl', dic: 'gl_ES', name: 'Galician' },
{ code: 'gu_IN', dic: 'gu_IN', name: 'Gujarati' },
{ code: 'gug_PY', dic: 'gug_PY', name: 'Guarani' },
{ code: 'he_IL', dic: 'he_IL', name: 'Hebrew' },
{ code: 'hi_IN', dic: 'hi_IN', name: 'Hindi' },
{ code: 'hr', dic: 'hr_HR', name: 'Croatian' },
{ code: 'hu_HU', dic: 'hu_HU', name: 'Hungarian' },
{ code: 'id', dic: 'id_ID', name: 'Indonesian' },
{ code: 'is_IS', dic: 'is_IS', name: 'Icelandic' },
{ code: 'it', dic: 'it_IT', name: 'Italian' },
{ code: 'kk', dic: 'kk_KZ', name: 'Kazakh' },
{ code: 'kmr', dic: 'kmr_Latn', name: 'Kurmanji' },
{ code: 'ko', dic: 'ko', name: 'Korean' },
{ code: 'lo_LA', dic: 'lo_LA', name: 'Laotian' },
{ code: 'lt', dic: 'lt_LT', name: 'Lithuanian' },
{ code: 'lv', dic: 'lv_LV', name: 'Latvian' },
{ code: 'ml_IN', dic: 'ml_IN', name: 'Malayalam' },
{ code: 'mn_MN', dic: 'mn_MN', name: 'Mongolian' },
{ code: 'nb_NO', dic: 'nb_NO', name: 'Norwegian (Bokmål)' },
{ code: 'ne_NP', dic: 'ne_NP', name: 'Nepali' },
{ code: 'nl', dic: 'nl', name: 'Dutch' },
{ code: 'nn_NO', dic: 'nn_NO', name: 'Norwegian (Nynorsk)' },
{ code: 'oc_FR', dic: 'oc_FR', name: 'Occitan' },
{ code: 'pl', dic: 'pl_PL', name: 'Polish' },
{ code: 'pt_BR', dic: 'pt_BR', name: 'Portuguese (Brazilian)' },
{ code: 'pt_PT', dic: 'pt_PT', name: 'Portuguese (European)' },
{ code: 'ro', dic: 'ro_RO', name: 'Romanian' },
{ code: 'ru', dic: 'ru_RU', name: 'Russian' },
{ code: 'si_LK', dic: 'si_LK', name: 'Sinhala' },
{ code: 'sk', dic: 'sk_SK', name: 'Slovak' },
{ code: 'sl', dic: 'sl_SI', name: 'Slovenian' },
{ code: 'sr_RS', dic: 'sr_RS', name: 'Serbian' },
{ code: 'sv', dic: 'sv_SE', name: 'Swedish' },
{ code: 'sw_TZ', dic: 'sw_TZ', name: 'Swahili' },
{ code: 'te_IN', dic: 'te_IN', name: 'Telugu' },
{ code: 'th_TH', dic: 'th_TH', name: 'Thai' },
{ code: 'tl', dic: 'tl', name: 'Tagalog' },
{ code: 'tr_TR', dic: 'tr_TR', name: 'Turkish' },
{ code: 'uz_UZ', dic: 'uz_UZ', name: 'Uzbek' },
{ code: 'vi_VN', dic: 'vi_VN', name: 'Vietnamese' },
]
const suggestions = {
af: ['medicyne', 'medisyne'],
be_BY: [екi', 'лекі'],
bg: [екарствo', 'лекарство'],
de: ['Medicin', 'Medizin'],
en_CA: ['theatr', 'theatre'],
en_GB: ['medecine', 'medicine'],
en_US: ['theatr', 'theater'],
es: ['medicaminto', 'medicamento'],
fr: ['medecin', 'médecin'],
sv: ['medecin', 'medicin'],
}
forEach(Object.keys(suggestions)).describe(
'Spell check in client (%s)',
(spellCheckLanguage: keyof typeof suggestions) => {
const content = `
\\documentclass{}
\\title{}
\\author{}
\\begin{document}
\\maketitle
\\begin{abstract}
\\end{abstract}
\\section{}
\\end{document}`
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-preventCompileOnLoad', true)
win.metaAttributesCache.set('ol-learnedWords', ['baz'])
win.metaAttributesCache.set(
'ol-dictionariesRoot',
`js/dictionaries/${PackageVersions.version.dictionaries}/`
)
win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
win.metaAttributesCache.set('ol-languages', languages)
})
cy.interceptEvents()
const scope = mockScope(content)
scope.project.spellCheckLanguage = spellCheckLanguage
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(13).as('line')
cy.get('@line').click()
})
it('shows suggestions for misspelled word', function () {
const [from, to] = suggestions[spellCheckLanguage]
cy.get('@line').type(`${from} ${to}`)
cy.get('@line')
.get('.ol-cm-spelling-error', { timeout: 10000 })
.should('have.length', 1)
cy.get('@line').get('.ol-cm-spelling-error').should('have.text', from)
cy.get('@line').get('.ol-cm-spelling-error').rightclick()
cy.findByRole('menuitem', { name: to }).click()
cy.get('@line').contains(`${to} ${to}`)
cy.get('@line').find('.ol-cm-spelling-error').should('not.exist')
})
}
)

View File

@@ -0,0 +1,787 @@
// Needed since eslint gets confused by mocha-each
/* eslint-disable mocha/prefer-arrow-callback */
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import forEach from 'mocha-each'
import { TestContainer } from '../helpers/test-container'
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.viewport(1000, 800)
cy.mount(
<TestContainer style={{ width: 1000, height: 800 }}>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// 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('<CodeMirrorEditor/> Table editor', function () {
beforeEach(function () {
cy.interceptEvents()
cy.interceptMathJax()
cy.interceptCompile({ prefix: 'compile', times: Number.MAX_SAFE_INTEGER })
cy.intercept('/project/*/doc/*/metadata', { body: {} })
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
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])
})
it('Renders math in cells', function () {
mountEditor(`
\\begin{tabular}{c}
$\\pi$
\\end{tabular}`)
cy.get('.MathJax').should('have.text', '$\\pi$')
})
})
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({ force: true })
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')
.should('contain.text', 'format_align_center')
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'],
])
// Check that alignment button updated to reflect the left alignment
cy.get('.table-generator-cell').first().click()
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('be.disabled')
.should('contain.text', 'format_align_left')
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}{|c|c|c|}
\\hline
cell 1 & cell 2 & cell 3 \\\\ \\hline
cell 4 & cell 5 & cell 6 \\\\ \\hline
cell 7 & cell 8 & cell 9 \\\\ \\hline
\\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(2).click()
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([
['cell 1', 'cell 3'],
['cell 4', 'cell 6'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
})
it('Removes rows correctly when removing from the left', function () {
mountEditor(`
\\begin{tabular}{|c|c|c|}\\hline
cell 1&cell 2&cell 3 \\\\\\hline
\\end{tabular}
`)
checkTable([['cell 1', 'cell 2', 'cell 3']])
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator')
.findByText('cell 1')
.type('{shift}{rightarrow}')
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.findByLabelText('Delete row or column')
.should('be.enabled')
cy.get('@toolbar').findByLabelText('Delete row or column').click()
checkTable([['cell 3']])
checkBordersWithNoMultiColumn([true, true], [true, true])
})
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')
// Set border theme to "All borders" so that we can check that theme is
// preserved when adding new rows and columns
cy.get('@toolbar').findByText('No borders').click()
cy.get('.table-generator').findByText('All borders').click()
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']])
checkBordersWithNoMultiColumn([true, true], [true, true, true])
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', '']])
checkBordersWithNoMultiColumn([true, true], [true, true, true, true])
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', ''],
])
checkBordersWithNoMultiColumn(
[true, true, true],
[true, true, true, true]
)
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', ''],
['', '', ''],
])
checkBordersWithNoMultiColumn(
[true, true, true, true],
[true, true, true, true]
)
})
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)
})
})
cy.get('@toolbar').findByText('Caption below').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Caption above')
.click()
// Check that caption is above 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.lessThan', tableYPosition)
})
})
// Removes caption when clicking "No caption"
cy.get('@toolbar').findByText('Caption above').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')
})
it('Renders a table with custom column spacing', function () {
mountEditor(`
\\begin{tabular}{@{}c@{}l!{}}
cell 1 & cell 2 \\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
cy.get('.table-generator-cell').first().click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar').findByText('No 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 })
checkTable([
['cell 1', 'cell 2'],
['cell 3', 'cell 4'],
])
checkBordersWithNoMultiColumn([true, true, true], [true, true, true])
})
it('Disables caption dropdown when not directly inside table environment', function () {
mountEditor(`
\\begin{table}
\\caption{Table caption}
\\label{tab:table}
\\begin{adjustbox}{max width=\\textwidth}
\\begin{tabular}{c}
cell 1
\\end{tabular}
\\end{adjustbox}
\\end{table}`)
cy.get('.table-generator').findByText('cell 1').click()
cy.get('.table-generator-floating-toolbar').as('toolbar').should('exist')
cy.get('@toolbar')
.contains('button', 'Caption above')
.should('be.disabled')
})
describe('Fixed width columns', function () {
it('Can add fixed width columns', function () {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{cc}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-column-indicator-label').should('not.exist')
cy.get('.table-generator-cell').eq(0).as('cell')
// Activate the table
cy.get('@cell').click()
cy.get('.table-generator-floating-toolbar')
.as('toolbar')
.should('exist')
// Select the second column
cy.get('.column-selector').eq(1).click()
cy.get('@toolbar').findByLabelText('Adjust column width').click()
cy.get('.table-generator-toolbar-dropdown-menu')
.findByText('Fixed width, wrap text')
.click()
// The modal should be open
cy.get('.table-generator-width-modal').as('modal').should('be.visible')
// The width input should be focused
cy.get('@modal')
.get('#column-width-modal-width')
.should('be.focused')
.type('20')
// Change the unit to inches
cy.get('@modal')
.findAllByLabelText(/Length unit/)
.first()
.click()
cy.get('@modal')
.findByRole('listbox')
.as('dropdown')
.should('be.visible')
cy.get('@dropdown').findByText('in').click()
// Confirm the change
cy.get('@modal').findByText('OK').click()
// Modal should close
cy.get('@modal').should('not.exist')
// Check that the width is applied to the right column
cy.get('.table-generator-column-widths-row td')
// 3rd element (buffer, column 1, column 2)
.eq(2)
.should('contain.text', '20in')
})
forEach([
['20in', 'in'],
['20', 'Custom'],
['\\foobar', 'Custom'],
]).it(
`Understands '%s' width descriptor`,
function (width, expectedUnit) {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{cp{${width}}}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
// Activate the table
cy.get('.table-generator-cell').eq(0).click()
// Click the column width indicator
cy.get('.table-generator-column-indicator-label').click()
// The modal should be open
cy.get('.table-generator-width-modal')
.as('modal')
.should('be.visible')
cy.get('@modal')
.findAllByLabelText(/Length unit/)
.first()
.should('have.value', expectedUnit)
cy.get('@modal').findByText('Cancel').click()
}
)
it(`It can justify fixed width cells`, function () {
// Check that no column indicators exist
mountEditor(`
\\begin{tabular}{>{\\raggedright\\arraybackslash}p{2cm}c}
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
// Activate the table
cy.get('.table-generator-cell').eq(0).click()
cy.get('.table-generator-floating-toolbar')
.as('toolbar')
.should('exist')
// Select the first column
cy.get('.column-selector').first().click()
// Verify current alignment is left, and open the menu
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('not.be.disabled')
.should('contain.text', 'format_align_left')
.click()
// Change to justified alignment
cy.get('.table-generator').findByLabelText('Justify').click()
// Verify that alignment icon and class alignments were updated
cy.get('@toolbar')
.findByLabelText('Alignment')
.should('contain.text', 'format_align_justify')
cy.get('.table-generator-cell')
.eq(0)
.should('have.class', 'alignment-paragraph')
cy.get('.table-generator-cell')
.eq(1)
.should('have.class', 'alignment-center')
cy.get('.table-generator-cell')
.eq(2)
.should('have.class', 'alignment-paragraph')
cy.get('.table-generator-cell')
.eq(3)
.should('have.class', 'alignment-center')
})
})
})
describe('Tabular interactions', function () {
it('Can type into cells', function () {
mountEditor(`
\\begin{tabular}{cccc}
cell 1 & cell 2 & cell 3 & cell 4\\\\
cell 5 & \\multicolumn{2}{c}cell 6} & cell 7 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(5).as('cell-6')
// Escape should cancel editing
cy.get('@cell-1').type('foo{Esc}')
cy.get('@cell-1').should('have.text', 'cell 1')
// Enter should commit change. Direct typing should override the current contents
cy.get('@cell-1').type('foo{Enter}')
cy.get('@cell-1').should('have.text', 'foo')
// Enter should start editing at the end of current text
cy.get('@cell-1').type('{Enter}')
cy.get('@cell-1').find('textarea').should('exist')
cy.get('@cell-1').type('bar{Enter}')
cy.get('@cell-1').should('have.text', 'foobar')
// Double clicking should start editing at the end of current text
cy.get('@cell-1').dblclick()
cy.get('@cell-1').find('textarea').should('exist')
cy.get('@cell-1').type('baz{Enter}')
cy.get('@cell-1').should('have.text', 'foobarbaz')
cy.get('@cell-1').type('{Backspace}')
cy.get('@cell-1').should('have.text', '')
// Typing also works for multicolumn cells
cy.get('@cell-6').type('foo{Enter}')
checkTable([
['', 'cell 2', 'cell 3', 'cell 4'],
['cell 5', { text: 'foo', colspan: 2 }, 'cell 7'],
])
})
it('Can paste tabular data into cells', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
// TODO: Seems as cypress can't access clipboard, so we can't test copying
cy.get('@cell-1').click()
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', 'foo\tbar\nbaz\tqux')
cy.get('@cell-1').trigger('paste', { clipboardData })
checkTable([
['foo', 'bar'],
['baz', 'qux'],
])
})
it('Can navigate cells with keyboard', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(1).as('cell-2')
cy.get('.table-generator-cell').eq(2).as('cell-3')
cy.get('.table-generator-cell').eq(3).as('cell-4')
// Arrow key navigation
cy.get('@cell-1').click()
cy.get('@cell-1').type('{rightarrow}')
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').type('{leftarrow}')
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
cy.get('@cell-1').type('{downarrow}')
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').type('{rightarrow}')
cy.get('@cell-4').should('have.focus').should('have.class', 'selected')
cy.get('@cell-4').type('{uparrow}')
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').type('{leftarrow}')
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
// Tab navigation
cy.get('@cell-1').tab()
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').tab()
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').tab()
cy.get('@cell-4').should('have.focus').should('have.class', 'selected')
cy.get('@cell-4').tab({ shift: true })
cy.get('@cell-3').should('have.focus').should('have.class', 'selected')
cy.get('@cell-3').tab({ shift: true })
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-2').tab({ shift: true })
cy.get('@cell-1').should('have.focus').should('have.class', 'selected')
// Tabbing when editing a cell should commit change and move to next cell
cy.get('@cell-1').type('foo')
cy.get('@cell-1').tab()
cy.get('@cell-2').should('have.focus').should('have.class', 'selected')
cy.get('@cell-1').should('have.text', 'foo')
})
it('Can select rows and columns with selectors', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1')
cy.get('.table-generator-cell').eq(1).as('cell-2')
cy.get('.table-generator-cell').eq(2).as('cell-3')
cy.get('.table-generator-cell').eq(3).as('cell-4')
cy.get('.column-selector').eq(0).click()
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('not.have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('not.have.class', 'selected')
cy.get('.column-selector').eq(1).click()
cy.get('@cell-1').should('not.have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('not.have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.column-selector').eq(0).click({ shiftKey: true })
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.row-selector').eq(0).click()
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('not.have.class', 'selected')
cy.get('@cell-4').should('not.have.class', 'selected')
cy.get('.row-selector').eq(1).click()
cy.get('@cell-1').should('not.have.class', 'selected')
cy.get('@cell-2').should('not.have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
cy.get('.row-selector').eq(0).click({ shiftKey: true })
cy.get('@cell-1').should('have.class', 'selected')
cy.get('@cell-2').should('have.class', 'selected')
cy.get('@cell-3').should('have.class', 'selected')
cy.get('@cell-4').should('have.class', 'selected')
})
it('Allow compilation shortcuts to work', function () {
mountEditor(`
\\begin{tabular}{cc }
cell 1 & cell 2\\\\
cell 3 & cell 4 \\\\
\\end{tabular}`)
cy.get('.table-generator-cell').eq(0).as('cell-1').click()
cy.get('@cell-1').type('{ctrl}{enter}')
cy.wait('@compile')
cy.get('@cell-1').type('foo{ctrl}{enter}')
cy.wait('@compile')
cy.get('@cell-1').type('{esc}')
cy.get('@cell-1').type('{ctrl}{s}')
cy.wait('@compile')
cy.get('@cell-1').type('foo{ctrl}{s}')
cy.wait('@compile')
})
})
})

View File

@@ -0,0 +1,191 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> command tooltip in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('shows a tooltip for \\href', function () {
const content = [
'\\documentclass{article}',
'\\usepackage{hyperref}',
'\\begin{document}',
'',
'\\end{document}',
].join('\n')
mountEditor(content)
cy.window().then(win => {
cy.stub(win, 'open').as('window-open')
})
// wait for preamble to be escaped
cy.get('.cm-line').eq(0).should('have.text', '')
// enter the command
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\href{{}}{{}foo')
cy.get('@content-line').should('have.text', '{foo}')
// enter the URL in the tooltip form
cy.findByLabelText('URL').type('https://example.com')
// open the link
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@window-open').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
// remove the link
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Remove link' }).click()
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', 'foo')
})
it('can navigate the \\href tooltip using the keyboard', function () {
const content = [
'\\documentclass{article}',
'\\usepackage{hyperref}',
'\\begin{document}',
'',
'\\end{document}',
].join('\n')
mountEditor(content)
// wait for preamble to be escaped
cy.get('.cm-line').eq(0).should('have.text', '')
// enter the command
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\href{{}}{{}foo')
// into tooltip URL form input
cy.tab()
// down to first button
cy.tab()
// back into tooltip URL form input
cy.tab({ shift: true })
// back into document
cy.tab({ shift: true })
// close the tooltip
cy.get('@content-line').trigger('keydown', { key: 'Escape' })
cy.findByRole('menu').should('have.length', 0)
})
it('shows a tooltip for \\url', function () {
mountEditor('')
cy.window().then(win => {
cy.stub(win, 'open').as('window-open')
})
// enter the command and URL
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\url{{}https://example.com')
cy.get('@content-line').should('have.text', '{https://example.com}')
// open the link
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@window-open').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
})
it('shows a tooltip for \\include', function () {
mountEditor('')
// enter the command and file name
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\include{{}foo')
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\include{foo}')
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// assert the unfocused command has a menu
cy.findByRole('menu').should('have.length', 1)
cy.findByText('Edit file')
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\include{figures/foo}')
})
it('shows a tooltip for \\input', function () {
mountEditor('')
// enter the command and file name
cy.get('.cm-line').eq(0).as('content-line')
cy.get('@content-line').type('\\input{{}foo')
// assert the focused command is undecorated
cy.get('@content-line').should('have.text', '\\input{foo}')
cy.contains('figures/foo.tex').click()
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
cy.get('@content-line').type('{leftArrow}')
// open the target
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Edit file' })
// assert the unfocused command is decorated
cy.get('@content-line').type('{downArrow}')
cy.findByRole('menu').should('have.length', 0)
cy.get('@content-line').should('have.text', '\\input{figures/foo}')
})
it('shows a tooltip for \\ref', function () {
const content = ['\\section{Foo} \\label{sec:foo}', ''].join('\n')
mountEditor(content)
// assert the unfocused label is decorated
cy.get('.cm-line').eq(0).as('heading-line')
cy.get('@heading-line').should('have.text', '{Foo} 🏷sec:foo')
// enter the command and cross-reference label
cy.get('.cm-line').eq(1).as('content-line')
cy.get('@content-line').type('\\ref{{}sec:foo')
cy.get('@content-line').should('have.text', '🏷{sec:foo}')
// open the target
cy.findByRole('menu').should('have.length', 1)
cy.findByRole('button', { name: 'Go to target' }).click()
cy.findByRole('menu').should('have.length', 0)
// assert the focused label is undecorated
cy.get('@heading-line').should('have.text', 'Foo \\label{sec:foo}')
})
})

View File

@@ -0,0 +1,63 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> floats', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('decorates a caption', function () {
mountEditor('\n\\caption{Foo}\n')
cy.get('.cm-line').eq(2).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a caption in a figure', function () {
mountEditor('\\begin{figure}\n\\caption{Foo}\n\\end{figure}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a caption in a table', function () {
mountEditor('\\begin{table}\n\\caption{Foo}\n\\end{table}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred caption', function () {
mountEditor('\n\\caption*{Foo}\n')
cy.get('.cm-line').eq(2).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred figure', function () {
mountEditor('\\begin{figure*}\n\\caption{Foo}\n\\end{figure*}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
it('decorates a starred table', function () {
mountEditor('\\begin{table*}\n\\caption{Foo}\n\\end{table*}\n')
cy.get('.cm-line').eq(3).click()
cy.get('.cm-content').should('have.text', 'Foo')
})
})

View File

@@ -0,0 +1,314 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('creates a nested list inside an unindented list', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// create a nested list
cy.get('.cm-line')
.eq(1)
.type(isMac ? '{cmd}]' : '{ctrl}]')
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('creates a nested list inside an indented list', function () {
const content = [
'\\begin{itemize}',
' \\item Test',
' \\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// create a nested list
cy.get('.cm-line')
.eq(1)
.type(isMac ? '{cmd}]' : '{ctrl}]')
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('creates a nested list on Tab at the start of an item', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// move to the start of the item and press Tab
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(4))
cy.get('@line').trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('does not creates a nested list on Tab when not at the start of an item', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// focus a line (at the end of a list item) and press Tab
cy.get('.cm-line').eq(2).click()
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test '].join(''))
})
it('removes a nested list on Shift-Tab', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// move to the start of the list item and press Tab
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(4))
cy.get('@line').trigger('keydown', {
key: 'Tab',
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
// focus the indented line and press Shift-Tab
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
shiftKey: true,
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('does not remove a top-level nested list on Shift-Tab', function () {
const content = [
'\\begin{itemize}',
'\\item Test',
'\\item Test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
// focus a list item and press Shift-Tab
cy.get('.cm-line').eq(2).click()
cy.get('.cm-line').eq(1).trigger('keydown', {
key: 'Tab',
shiftKey: true,
})
cy.get('.cm-content').should('have.text', [' Test', ' Test'].join(''))
})
it('handles up arrow at the start of a list item', function () {
const content = [
'\\begin{itemize}',
'\\item One',
'\\item Two',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item
cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('One')
})
})
it('handles up arrow at the start of an indented list item', function () {
const content = [
'\\begin{itemize}',
' \\item One',
' \\item Two',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).as('line')
cy.get('@line').click()
cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item
cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('One')
})
})
it('handles keyboard navigation around a list', function () {
const content = [
'',
'\\begin{itemize}',
'\\item One',
'\\item Two',
'\\end{itemize}',
'',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(0).as('line')
cy.get('@line').click('left')
cy.get('@line').type(
'{downArrow}'.repeat(2) + // down to the second list item
'{rightArrow}'.repeat(2) + // along a few characters
'{upArrow}'.repeat(1) + // up to the first list item
'{rightArrow}'.repeat(2) + // along to the start of the second list item
'{shift}' + // start extending the selection
'{rightArrow}'.repeat(3) // cover the word
)
cy.window().should(win => {
expect(win.getSelection()?.toString()).to.equal('Two')
})
})
it('positions the cursor after creating a new line with leading whitespace', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).click()
cy.get('.cm-line').eq(0).type('{leftArrow}'.repeat(4))
cy.get('.cm-line').eq(0).type('{enter}baz')
cy.get('.cm-content').should('have.text', [' foo', ' bazbar'].join(''))
})
it('handles Enter in an empty list item at the end of a top-level list', function () {
const content = [
'\\begin{itemize}',
'\\item foo',
'\\item ',
'\\end{itemize}',
'',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(2).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should('have.text', [' foo'].join(''))
})
it('handles Enter in an empty list item at the end of a nested list', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\begin{itemize}',
'\\item baz',
'\\item ',
'\\end{itemize}',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(3).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should(
'have.text',
[' foo', ' bar', ' baz', ' '].join('')
)
})
it('handles Enter in an empty list item at the end of a nested list with subsequent items', function () {
const content = [
'\\begin{itemize}',
'\\item foo bar',
'\\begin{itemize}',
'\\item baz',
'\\item ',
'\\end{itemize}',
'\\item test',
'\\end{itemize}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(3).click()
cy.focused().type('{enter}')
cy.get('.cm-content').should(
'have.text',
[' foo', ' bar', ' baz', ' ', ' test'].join('')
)
})
it('decorates a description list', function () {
const content = [
'\\begin{description}',
'\\item[foo] Bar',
'\\item Test',
'\\end{description}',
].join('\n')
mountEditor(content)
cy.get('.cm-line').eq(1).click()
cy.get('.cm-content').should('have.text', ['foo Bar', 'Test'].join(''))
cy.get('.cm-line').eq(1).type('{Enter}baz')
cy.get('.cm-content').should(
'have.text',
['foo Bar', 'Test', '[baz] '].join('')
)
cy.get('.cm-line').eq(2).type('{rightArrow}{rightArrow}Test')
cy.get('.cm-content').should(
'have.text',
['foo Bar', 'Test', 'baz Test'].join('')
)
})
})

View File

@@ -0,0 +1,802 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
const menuIconsText = 'content_copyexpand_more'
const mountEditor = (content = '') => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// 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)
cy.interceptEvents()
})
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').type('{esc}')
cy.get('@content').should('have.text', 'foo' + menuIconsText)
cy.get('@get-data').should('have.been.calledTwice')
cy.get('@get-data').should('have.been.calledWithExactly', 'text/html')
cy.get('@get-data').should('have.been.calledWithExactly', 'text/plain')
})
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.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar' + menuIconsText)
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.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 2)
})
it('handles a pasted nested bullet list', function () {
mountEditor()
const data =
'<ul><li>foo</li><li><ul><li>bar</li><li>baz</li></ul></li></ul>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar baz' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 4)
cy.get('.cm-line').should('have.length', 6)
})
it('handles a pasted nested numbered list', function () {
mountEditor()
const data =
'<ol><li>foo</li><li><ol><li>bar</li><li>baz</li></ol></li></ol>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', ' foo bar baz' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 4)
cy.get('.cm-line').should('have.length', 6)
})
it('removes a solitary item from a list', function () {
mountEditor()
const data = '<ul><li>foo</li></ul>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo' + menuIconsText)
cy.get('.ol-cm-item').should('have.length', 0)
})
it('handles a pasted 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.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
})
it('handles a pasted table with cell borders', function () {
mountEditor()
const data =
'<table><tbody><tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.table-generator-cell-border-left').should('have.length', 1)
cy.get('.table-generator-cell-border-right').should('have.length', 2)
cy.get('.table-generator-row-border-top').should('have.length', 2)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with row borders', function () {
mountEditor()
const data =
'<table><tbody><tr style="border-top:1px solid black;border-bottom:1px solid black"><td>foo</td><td>bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.table-generator-cell-border-left').should('have.length', 0)
cy.get('.table-generator-cell-border-right').should('have.length', 0)
cy.get('.table-generator-row-border-top').should('have.length', 2)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with adjacent borders', function () {
mountEditor()
const data = [
'<table><tbody>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'</tbody></table>',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobarfoobarfoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 6)
cy.get('.table-generator-cell-border-left').should('have.length', 3)
cy.get('.table-generator-cell-border-right').should('have.length', 6)
cy.get('.table-generator-row-border-top').should('have.length', 6)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with alignment', function () {
mountEditor()
const data =
'<table><tbody><tr><td>foo</td><td style="text-align:left">foo</td><td style="text-align:center">foo</td><td style="text-align:right">foo</td><td style="text-align:justify">foo</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foofoofoofoofoo' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 5)
cy.get('.table-generator-cell.alignment-left').should('have.length', 3)
cy.get('.table-generator-cell.alignment-center').should('have.length', 1)
cy.get('.table-generator-cell.alignment-right').should('have.length', 1)
})
it('handles a pasted table with merged columns', 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" style="text-align:right">test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttesttesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 7)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 2)
})
it('handles a pasted table with merged rows', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`<tr><td rowspan="2">test</td><td>test</td><td>test</td></tr>`,
`<tr><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttest\\multirow{2}{*}{test}testtesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 9)
})
it('handles a pasted table with merged rows and columns', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td colspan="2" rowspan="2">test</td><td>test</td></tr>`,
`<tr><td>test</td></tr>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'\\multirow{2}{*}{test}testtesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 8)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1)
})
it('ignores rowspan="1" and colspan="1"', function () {
mountEditor()
const data = [
`<table><tbody>`,
`<tr><td colspan="1" rowspan="1">test</td><td>test</td><td>test</td></tr>`,
`<tr><td>test</td><td>test</td><td>test</td></tr>`,
`</tbody></table>`,
].join('')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'testtesttesttesttesttest' + menuIconsText
)
cy.get('.table-generator-cell').should('have.length', 6)
cy.get('.table-generator-cell[colspan]').should('have.length', 0)
})
it('handles a pasted table with adjacent borders and merged cells', function () {
mountEditor()
const data = [
'<table><tbody>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black" colspan="2">foo</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'<tr><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">foo</td><td style="border-left:1px solid black;border-right:1px solid black;border-top:1px solid black;border-bottom:1px solid black">bar</td></tr>',
'</tbody></table>',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foofoobarfoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 5)
cy.get('.table-generator-cell[colspan="2"]').should('have.length', 1)
cy.get('.table-generator-cell-border-left').should('have.length', 2)
cy.get('.table-generator-cell-border-right').should('have.length', 4)
cy.get('.table-generator-row-border-top').should('have.length', 5)
cy.get('.table-generator-row-border-bottom').should('have.length', 2)
})
it('handles a pasted table with cell styles', function () {
mountEditor()
const data =
'<table><tbody><tr><td style="font-weight:bold">foo</td><td style="font-style:italic">bar</td><td style="font-style:italic;font-weight:bold">baz</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobarbaz' + menuIconsText)
cy.findByText(/Sorry/).should('not.exist')
cy.get('td b').should('have.length', 2)
cy.get('td i').should('have.length', 2)
})
it('handles a pasted table with formatting markup', function () {
mountEditor()
const data =
'<table><tbody><tr>' +
'<td><b>foo</b></td>' +
'<td><i>bar</i></td>' +
'<td><b><i>baz</i></b></td>' +
'<td><i><b>buzz</b></i></td>' +
'<td><sup>up</sup></td>' +
'<td><sub>down</sub></td>' +
'</tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'foobarbazbuzzupdown' + menuIconsText
)
cy.findByText(/Sorry/).should('not.exist')
cy.get('td b').should('have.length', 3)
cy.get('td i').should('have.length', 3)
cy.get('td sup').should('have.length', 1)
cy.get('td sub').should('have.length', 1)
})
it('handles a pasted table with a caption', function () {
mountEditor()
const data =
'<table><caption>A table</caption><tbody><tr><td>foo</td><td>bar</td></tr></tbody></table>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'A tablefoobar' + menuIconsText)
cy.get('.table-generator-cell').should('have.length', 2)
cy.get('.ol-cm-command-caption').should('have.length', 1)
})
it('handles a pasted link', function () {
mountEditor()
const data =
'<a href="https://example.com/?q=$foo_~bar&x=\\bar#fragment{y}%2">foo</a>'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', '{foo}' + menuIconsText)
cy.get('.ol-cm-command-href').should('have.length', 1)
cy.get('.cm-line').eq(0).type('{leftArrow}')
cy.findByLabelText('URL').should(
'have.value',
'https://example.com/?q=$foo_~bar&x=\\\\bar\\#fragment%7By%7D\\%2'
)
// TODO: assert that the "Go to page" link has been unescaped
})
it('handles pasted code in pre blocks', function () {
mountEditor()
const data = `test <pre><code>\\textbf{foo}</code></pre> <pre style="font-family: 'Lucida Console', monospace">\\textbf{foo}</pre> test`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'test \\textbf{foo}\\textbf{foo}test' + menuIconsText
)
cy.get('.ol-cm-environment-verbatim').should('have.length', 10)
})
it('handles a pasted blockquote', function () {
mountEditor()
const data = 'test <blockquote>foo</blockquote> test'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test footest' + menuIconsText)
cy.get('.ol-cm-environment-quote').should('have.length', 5)
cy.get('.cm-line').eq(2).click()
cy.get('@content').should(
'have.text',
'test \\begin{quote}foo\\end{quote}test' + menuIconsText
)
})
it('handles pasted paragraphs', function () {
mountEditor()
const data = [
'test',
'<p>foo</p>',
'<p>bar</p>',
'<p>baz</p>',
'test',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test foobarbaztest' + menuIconsText)
cy.get('.cm-line').should('have.length', 7)
})
it('handles pasted paragraphs in list items and table cells', function () {
mountEditor()
const data = [
'test',
'<p>foo</p><p>bar</p><p>baz</p>',
'<ul><li><p>foo</p></li><li><p>foo</p></li></ul>',
'<ol><li><p>foo</p></li><li><p>foo</p></li></ol>',
'<table><tbody><tr><td><p>foo</p></td></tr></tbody></table>',
'test',
].join('\n')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'test foobarbaz foo foo foo foofootest' + menuIconsText
)
cy.get('.cm-line').should('have.length', 14)
})
it('handles pasted inline code', function () {
mountEditor()
const data = 'test <code>foo</code> test'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'test foo test' + menuIconsText)
cy.get('.ol-cm-command-verb')
.should('have.length', 1)
.should('have.text', 'foo')
})
it('use text/plain for a wrapper code element', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<code>foo</code>')
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
})
it('use text/plain for a code element in a pre element', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<pre><code>foo</code></pre>')
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
cy.get('.ol-cm-environment-verbatim').should('have.length', 0)
})
it('use text/plain for a pre element with monospace font', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData(
'text/html',
'<pre style="font-family:Courier,monospace">foo</pre>'
)
clipboardData.setData('text/plain', 'foo')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-verb').should('have.length', 0)
cy.get('.ol-cm-environment-verbatim').should('have.length', 0)
})
it('handles pasted text with formatting', function () {
mountEditor()
const data =
'<b>foo</b><sup>th</sup> <i>bar</i><sub>2</sub> baz <em>woo</em> <strong>woo</strong> woo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'footh bar2 baz woo woo woo' + menuIconsText
)
cy.get('.ol-cm-command-textbf').should('have.length', 2)
cy.get('.ol-cm-command-textit').should('have.length', 2)
cy.get('.ol-cm-command-textsuperscript').should('have.length', 1)
cy.get('.ol-cm-command-textsubscript').should('have.length', 1)
})
it('handles pasted text with bold CSS formatting', function () {
mountEditor()
const data =
'<span style="font-weight:bold">foo</span> <span style="font-weight:800">foo</span> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textbf').should('have.length', 2)
})
it('handles pasted text with italic CSS formatting', function () {
mountEditor()
const data = '<span style="font-style:italic">foo</span> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo' + menuIconsText)
cy.get('.ol-cm-command-textit').should('have.length', 1)
})
it('handles pasted text with non-bold CSS', function () {
mountEditor()
const data =
'<strong style="font-weight:normal">foo</strong> <strong style="font-weight:200">foo</strong> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textbf').should('have.length', 0)
})
it('handles pasted text with non-italic CSS', function () {
mountEditor()
const data =
'<em style="font-style:normal">foo</em> <i style="font-style:normal">foo</i> foo'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo foo foo' + menuIconsText)
cy.get('.ol-cm-command-textit').should('have.length', 0)
})
it('handles pasted elements with duplicate CSS formatting', function () {
mountEditor()
const data = [
'<strong style="font-weight:bold">foo</strong>',
'<b style="font-weight:bold">foo</b>',
'<em style="font-style:italic">foo</em>',
'<i style="font-style:italic">foo</i>',
'foo',
].join(' ')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.ol-cm-command-textbf').should('have.length', 2)
cy.get('.ol-cm-command-textit').should('have.length', 2)
})
it('removes a non-breaking space when a text node contains no other content', function () {
mountEditor()
const data = 'foo<span>\xa0</span>bar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo bar' + menuIconsText)
})
it('does not remove a non-breaking space when a text node contains other content', function () {
mountEditor()
const data = 'foo\xa0bar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo bar' + menuIconsText)
})
it('removes all zero-width spaces', function () {
mountEditor()
const data = 'foo\u200bbar'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foobar' + menuIconsText)
})
it('ignores HTML pasted from VS Code', function () {
mountEditor()
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<b>foo</b>')
clipboardData.setData('text/plain', 'foo')
clipboardData.setData('application/vnd.code.copymetadata', 'test')
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo')
cy.get('.ol-cm-command-textbf').should('have.length', 0)
})
it('protects special characters', function () {
mountEditor()
const data = 'foo & bar~baz'
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo & bar~baz' + menuIconsText)
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.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should(
'have.text',
'foo & bar~baz \\verb|\\textbf{foo}|' + menuIconsText
)
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)
})
// FIXME: need to assert on source code
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('tidies whitespace in pasted tables', function () {
mountEditor()
const data = `<table>
<tr>
<td>
<p><b>test</b></p>
</td>
</tr>
</table>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.cm-line').should('have.length', 8)
})
it('tidies whitespace in pasted lists', function () {
mountEditor()
const data = `<ul>
<li> foo </li>
<li>
<p>
<b>test</b></p>
<p>test test test
test test
test test test</p>
</li>
</ul>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('.cm-line').should('have.length', 6)
cy.get('@content').should(
'have.text',
' foo testtest test test test test test test test' + menuIconsText
)
})
it('collapses whitespace in adjacent inline elements', function () {
mountEditor()
const data = `<p><b> foo </b><span> test </span><i> bar </i> baz</p>`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', data)
cy.get('@content').trigger('paste', { clipboardData })
cy.get('@content').should('have.text', 'foo test bar baz' + menuIconsText)
})
it('treats a pasted image as a figure even if there is HTML', function () {
mountEditor()
cy.fixture<Uint8Array>('images/gradient.png').then(image => {
const file = new File([image], 'gradient.png', { type: 'image/png' })
const html = `<meta charset="utf-8"><img src="https://example.com/gradient.png" alt="gradient">`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.items.add(file)
cy.get('.cm-content').trigger('paste', { clipboardData })
// figure modal paste handler should appear
cy.findByText('Upload from computer').should('be.visible')
})
})
it('does not treat a pasted image as a figure if there is Office HTML', function () {
mountEditor()
cy.fixture<Uint8Array>('images/gradient.png').then(image => {
const file = new File([image], 'gradient.png', { type: 'image/png' })
const html = `<meta charset="utf-8"><meta name="ProgId" content="MS.Word"><img src="https://example.com/gradient.png" alt="gradient">`
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', html)
clipboardData.items.add(file)
cy.get('.cm-content').trigger('paste', { clipboardData })
// paste options button should appear
cy.findByLabelText('Paste options').should('be.visible')
})
})
})

View File

@@ -0,0 +1,148 @@
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'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.returns({ url: '/images/frog.jpg', extension: 'jpg' }),
}}
>
{children}
</FileTreePathContext.Provider>
)
const PermissionsProvider: FC = ({ children }) => (
<PermissionsContext.Provider
value={{
read: true,
comment: true,
resolveOwnComments: false,
resolveAllComments: false,
trackedWrite: false,
write: false,
admin: false,
labelVersion: false,
}}
>
{children}
</PermissionsContext.Provider>
)
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.permissions.write = false
scope.permissions.trackedWrite = false
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders
scope={scope}
providers={{ FileTreePathProvider, PermissionsProvider }}
>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> in Visual mode with read-only permission', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptEvents()
cy.interceptMetadata()
})
it('decorates footnote content', function () {
mountEditor('Foo \\footnote{Bar.} ')
// 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')
})
it('does not display the table toolbar', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}')
cy.get('.table-generator-floating-toolbar').should('not.exist')
cy.get('.table-generator-cell').click()
cy.get('.table-generator-floating-toolbar').should('not.exist')
})
it('does not enter a table cell on double-click', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}\n\n')
cy.get('.table-generator-cell').dblclick()
cy.get('.table-generator-cell').get('textarea').should('not.exist')
})
it('does not enter a table cell on Enter', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}')
cy.get('.table-generator-cell').trigger('keydown', { key: 'Enter' })
cy.get('.table-generator-cell').get('textarea').should('not.exist')
})
it('does not paste into a table cell', function () {
mountEditor('\\begin{tabular}{c}\n cell\n\\end{tabular}\n\n')
cy.get('.cm-line').last().click()
cy.get('.table-generator-cell-render').eq(0).click()
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', 'bar')
cy.get('.table-generator-cell-render')
.eq(0)
.trigger('paste', { clipboardData })
cy.get('.cm-content').should('have.text', 'cell')
})
it('does not display the figure edit button', function () {
cy.intercept('/images/frog.jpg', { fixture: 'images/gradient.png' })
mountEditor(
`\\begin{figure}
\\centering
\\includegraphics[width=0.5\\linewidth]{frog.jpg}
\\caption{My caption}
\\label{fig:my-label}
\\end{figure}`
)
cy.get('img.ol-cm-graphics').should('have.length', 1)
cy.findByRole('button', { name: 'Edit figure' }).should('not.exist')
})
it('does not display editing features in the href tooltip', function () {
mountEditor('\\href{https://example.com/}{foo}\n\n')
// move the selection outside the link
cy.get('.cm-line').eq(2).click()
// put the selection inside the href command
cy.findByText('foo').click()
cy.findByRole('button', { name: 'Go to page' })
cy.findByLabelText('URL').should('be.disabled')
cy.findByRole('button', { name: 'Remove link' }).should('not.exist')
})
})

View File

@@ -0,0 +1,332 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { mockScope } from '../helpers/mock-scope'
import { TestContainer } from '../helpers/test-container'
import { isMac } from '@/shared/utils/os'
const selectAll = () => {
cy.get('.cm-content').trigger(
'keydown',
isMac ? { key: 'a', metaKey: true } : { key: 'a', ctrlKey: true }
)
}
const clickToolbarButton = (name: string) => {
cy.findByRole('button', { name }).click()
cy.findByRole('button', { name }).trigger('mouseout')
}
const mountEditor = (content: string) => {
const scope = mockScope(content)
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
}
describe('<CodeMirrorEditor/> toolbar in Rich Text mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
})
it('should handle Undo and Redo', function () {
mountEditor('')
cy.get('.cm-line').eq(0).type('hi')
cy.get('.cm-content').should('have.text', 'hi')
clickToolbarButton('Undo')
cy.get('.cm-content').should('have.text', '')
clickToolbarButton('Redo')
cy.get('.cm-content').should('have.text', 'hi')
})
it('should handle section level changes', function () {
mountEditor('hi')
cy.get('.cm-content').should('have.text', 'hi')
clickToolbarButton('Choose section heading level')
cy.findByRole('menu').within(() => {
cy.findByText('Subsection').click()
})
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-subsection').should('have.length', 1)
clickToolbarButton('Choose section heading level')
cy.findByRole('menu').within(() => {
cy.findByText('Normal text').click()
})
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-subsection').should('have.length', 0)
})
it('should toggle Bold and Italic', function () {
mountEditor('hi')
cy.get('.cm-content').should('have.text', 'hi')
selectAll()
// bold
clickToolbarButton('Format Bold')
cy.get('.cm-content').should('have.text', '{hi}')
cy.get('.ol-cm-command-textbf').should('have.length', 1)
clickToolbarButton('Format Bold')
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-textbf').should('have.length', 0)
// italic
clickToolbarButton('Format Italic')
cy.get('.cm-content').should('have.text', '{hi}')
cy.get('.ol-cm-command-textit').should('have.length', 1)
clickToolbarButton('Format Italic')
cy.get('.cm-content').should('have.text', 'hi')
cy.get('.ol-cm-command-textit').should('have.length', 0)
})
it('should wrap content with inline math', function () {
mountEditor('2+3=5')
selectAll()
clickToolbarButton('Insert Math')
cy.findByRole('button', { name: 'Insert Inline Math' }).click()
cy.get('.cm-content').should('have.text', '\\(2+3=5\\)')
})
it('should wrap content with display math', function () {
mountEditor('2+3=5')
selectAll()
clickToolbarButton('Insert Math')
cy.findByRole('button', { name: 'Insert Display Math' }).click()
cy.get('.cm-content').should('have.text', '\\[2+3=5\\]')
})
it('should wrap content with a link', function () {
mountEditor('test')
selectAll()
clickToolbarButton('Insert Link')
cy.get('.cm-content').should('have.text', '{test}')
cy.findByLabelText('URL') // tooltip form
})
it('should insert a bullet list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Bullet List')
cy.get('.cm-content').should('have.text', ' test')
cy.get('.cm-line').eq(0).type('ing')
cy.get('.cm-line').eq(0).should('have.text', ' testing')
})
it('should insert a numbered list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
cy.get('.cm-content').should('have.text', ' test')
cy.get('.cm-line').eq(0).type('ing')
cy.get('.cm-line').eq(0).should('have.text', ' testing')
})
it('should toggle between list types', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(0).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
'\\end{enumerate}',
].join('')
)
clickToolbarButton('Bullet List')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{itemize}',
' test',
'\\end{itemize}',
].join('')
)
})
it('should remove a list', function () {
mountEditor('test')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(0).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
'\\end{enumerate}',
].join('')
)
clickToolbarButton('Numbered List')
cy.get('.cm-content').should('have.text', 'test')
})
it('should not remove a parent list', function () {
mountEditor('test\ntest')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(2).click()
cy.findByRole('button', { name: 'Increase Indent' }).click()
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
' test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(1).click()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
cy.get('.cm-line').eq(0).type('{upArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
})
it('should not remove a nested list', function () {
mountEditor('test\ntest')
selectAll()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'\\begin{enumerate}',
' test',
' test',
'\\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(2).click()
cy.findByRole('button', { name: 'Increase Indent' }).click()
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
' test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
cy.get('.cm-line').eq(0).click()
clickToolbarButton('More')
clickToolbarButton('Numbered List')
// expose the markup
cy.get('.cm-line').eq(1).type('{rightArrow}')
cy.get('.cm-content').should(
'have.text',
[
//
'test',
' \\begin{enumerate}',
' test',
' \\end{enumerate}',
].join('')
)
})
it('should display the Toggle Symbol Palette button when available', function () {
window.metaAttributesCache.set('ol-symbolPaletteAvailable', true)
mountEditor('')
clickToolbarButton('Toggle Symbol Palette')
})
it('should not display the Toggle Symbol Palette button when not available', function () {
window.metaAttributesCache.set('ol-symbolPaletteAvailable', false)
mountEditor('')
cy.findByLabelText('Toggle Symbol Palette').should('not.exist')
})
})

View File

@@ -0,0 +1,114 @@
import { mockScope } from '../helpers/mock-scope'
import { EditorProviders } from '../../../helpers/editor-providers'
import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> tooltips in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptMathJax()
cy.interceptMetadata()
cy.interceptEvents()
const scope = mockScope('\n\n\n')
scope.editor.showVisual = true
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').eq(0).as('first-line')
})
it('displays a tooltip for \\href commands', function () {
cy.get('@first-line').type(
'\\href{{}https://example.com}{{}foo}{leftArrow}'
)
cy.get('.cm-content').should('have.text', '{foo}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByLabelText('URL').should('have.value', 'https://example.com')
cy.findByLabelText('URL').type('/foo')
cy.findByLabelText('URL').should('have.value', 'https://example.com/foo')
cy.window().then(win => {
cy.stub(win, 'open').as('open-window')
})
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@open-window').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/foo'),
'_blank'
)
cy.findByRole('button', { name: 'Remove link' }).click()
})
cy.get('.cm-content').should('have.text', 'foo')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\url commands', function () {
cy.get('@first-line').type('\\url{{}https://example.com}{leftArrow}')
cy.get('.cm-content').should('have.text', '{https://example.com}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.window().then(win => {
cy.stub(win, 'open').as('open-window')
})
cy.findByRole('button', { name: 'Go to page' }).click()
cy.get('@open-window').should(
'have.been.calledWithMatch',
Cypress.sinon.match.has('href', 'https://example.com/'),
'_blank'
)
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', 'https://example.com')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\ref commands', function () {
cy.get('@first-line').type(
'\\label{{}fig:frog}{Enter}\\ref{{}fig:frog}{leftArrow}'
)
cy.get('.cm-content').should('have.text', '🏷fig:frog🏷{fig:frog}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Go to target' }).click()
})
cy.window().then(win => {
expect(win.getSelection()?.toString()).to.equal('fig:frog')
})
})
it('displays a tooltip for \\include commands', function () {
cy.get('@first-line').type('\\include{{}main}{leftArrow}')
cy.get('.cm-content').should('have.text', '\\include{main}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Edit file' }).click()
// TODO: assert event fired with "main.tex" as the name?
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', '🔗main')
cy.get('.cm-tooltip').should('have.length', 0)
})
it('displays a tooltip for \\input commands', function () {
cy.get('@first-line').type('\\input{{}main}{leftArrow}')
cy.get('.cm-content').should('have.text', '\\input{main}')
cy.get('.cm-tooltip').should('have.length', 1)
cy.get('.cm-tooltip').within(() => {
cy.findByRole('button', { name: 'Edit file' }).click()
// TODO: assert event fired with "main.tex" as the name?
})
cy.get('@first-line').type('{rightArrow}{rightArrow}')
cy.get('.cm-content').should('have.text', '🔗main')
cy.get('.cm-tooltip').should('have.length', 0)
})
})

View File

@@ -0,0 +1,735 @@
// Needed since eslint gets confused by mocha-each
/* eslint-disable mocha/prefer-arrow-callback */
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'
import forEach from 'mocha-each'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { TestContainer } from '../helpers/test-container'
describe('<CodeMirrorEditor/> in Visual mode', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
cy.interceptMetadata()
cy.interceptMathJax()
// 3 blank lines
const content = '\n'.repeat(3)
const scope = mockScope(content)
scope.editor.showVisual = true
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy.stub(),
pathInFolder: cy.stub(),
previewByPath: cy
.stub()
.as('previewByPath')
.callsFake(path => ({ url: path, extension: 'png' })),
}}
>
{children}
</FileTreePathContext.Provider>
)
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ FileTreePathProvider }}>
<CodemirrorEditor />
</EditorProviders>
</TestContainer>
)
// wait for the content to be parsed and revealed
cy.get('.cm-content').should('have.css', 'opacity', '1')
cy.get('.cm-line').eq(0).as('first-line')
cy.get('.cm-line').eq(1).as('second-line')
cy.get('.cm-line').eq(2).as('third-line')
cy.get('.cm-line').eq(3).as('fourth-line')
cy.get('.ol-cm-toolbar [aria-label="Format Bold"]').as('toolbar-bold')
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)
})
it('renders \\dots', function () {
cy.get('@first-line').type('\\dots{Esc}')
cy.get('@first-line').should('have.text', '\\dots')
cy.get('@first-line').type('{Enter}')
cy.get('@first-line').should('have.text', '…')
})
it('creates a new list item on Enter', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@first-line')
.should('have.text', ' ')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('@first-line').type('test{Enter}test')
cy.get('@first-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('@second-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
})
it('finishes a list on Enter in the last item if empty', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@first-line').type('test{Enter}{Enter}')
cy.get('@first-line')
.should('have.text', ' test')
.find('.ol-cm-item')
.should('have.length', 1)
cy.get('.cm-line').eq(1).should('have.text', '')
})
it('does not finish a list on Enter in an earlier item if empty', function () {
cy.get('@first-line').type('\\begin{{}itemize')
// select the first autocomplete item
cy.findByRole('option').eq(0).click()
cy.get('@second-line').type('test{Enter}test{Enter}{upArrow}{Enter}{Enter}')
cy.get('.cm-content').should('have.text', ' testtest')
})
forEach(['textbf', 'textit']).it('handles \\%s text', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', '{} ')
cy.get('@first-line').type('{Backspace}{leftArrow}test text')
cy.get('@first-line').should('have.text', '{test text}')
cy.get('@first-line').type('{rightArrow} foo')
cy.get('@first-line').should('have.text', 'test text foo') // no braces
cy.get('@first-line').find(`.ol-cm-command-${command}`)
})
forEach([
'part',
'chapter',
'section',
'subsection',
'subsubsection',
'paragraph',
'subparagraph',
]).it('handles \\%s sectioning command', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `\\${command}{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', `\\${command}{} `)
// Type a section heading
cy.get('@first-line').type('{Backspace}{leftArrow}title')
cy.get('@first-line').should('have.text', '{title}') // braces are visible as cursor is adjacent
cy.get('@first-line').type('{leftArrow}')
cy.get('@first-line').should('have.text', 'title') // braces are hidden as cursor is not adjacent
cy.get('@first-line').type('{Enter}')
cy.get('@first-line').should('have.text', 'title') // braces are hidden as cursor is on the next line
cy.get('@first-line').find(`.ol-cm-heading.ol-cm-command-${command}`)
})
forEach([
'textsc',
'texttt',
'textmd',
'textsf',
'textsubscript',
'textsuperscript',
'sout',
'emph',
'underline',
'url',
'caption',
]).it('handles \\%s text', function (command) {
cy.get('@first-line').type(`\\${command}{`)
cy.get('@first-line').should('have.text', `\\${command}{}`)
cy.get('@first-line').type('{rightArrow} ')
cy.get('@first-line').should('have.text', `\\${command}{} `)
cy.get('@first-line').type('{Backspace}{leftArrow}test text{rightArrow} ')
cy.get('@first-line').should('have.text', 'test text ')
cy.get('@first-line').find(`.ol-cm-command-${command}`)
})
it('handles \\verb text', function () {
cy.get('@first-line').type(`\\verb|`)
cy.get('@first-line').should('have.text', `\\verb|`)
cy.get('@first-line').type('| ')
cy.get('@first-line').should('have.text', `\\verb|| `)
cy.get('@first-line').type('{Backspace}{leftArrow}test text{rightArrow} ')
cy.get('@first-line').should('have.text', 'test text ')
cy.get('@first-line').find(`.ol-cm-command-verb`)
})
forEach([
['label', '🏷'],
['cite', '📚'],
['include', '🔗'],
]).it('handles \\%s commands', function (command, icon) {
cy.get('@first-line').type(`\\${command}{} `)
cy.get('@first-line').should('have.text', `\\${command}{} `)
cy.get('@first-line').type('{Backspace}{leftArrow}key')
cy.get('@first-line').should('have.text', `\\${command}{key}`)
cy.get('@first-line').type('{rightArrow}')
cy.get('@first-line').should('have.text', `\\${command}{key}`)
cy.get('@first-line').type(' ')
cy.get('@first-line').should('have.text', `${icon}key `)
})
forEach([['ref', '🏷']]).it(
'handles \\%s commands',
function (command, icon) {
cy.get('@first-line').type(`\\${command}{} `)
cy.get('@first-line').should('have.text', `${icon} `)
cy.get('@first-line').type('{Backspace}{leftArrow}key')
cy.get('@first-line').should('have.text', `${icon}{key}`)
cy.get('@first-line').type('{rightArrow}')
cy.get('@first-line').should('have.text', `${icon}{key}`)
cy.get('@first-line').type(' ')
cy.get('@first-line').should('have.text', `${icon}key `)
}
)
it('handles \\href command', function () {
cy.get('@first-line').type('\\href{{}https://overleaf.com} ')
cy.get('@first-line').should('have.text', '\\href{https://overleaf.com} ')
cy.get('@first-line').type('{Backspace}{{}{Del}Overleaf ')
cy.get('@first-line').should(
'have.text',
'\\href{https://overleaf.com}{Overleaf '
)
cy.get('@first-line').type('{Backspace}} ')
cy.get('@first-line').should('have.text', 'Overleaf ')
cy.get('@first-line').find('.ol-cm-link-text')
})
it('displays unknown commands unchanged', function () {
cy.get('@first-line').type('\\foo[bar]{{}baz} ')
cy.get('@first-line').should('have.text', '\\foo[bar]{baz} ')
})
describe('Figure environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}figure')
cy.get('@first-line').type('{Enter}') // end with cursor in file path
})
it('loads figures', function () {
cy.get('@third-line').type('path/to/image')
cy.get('@third-line').should(
'contain.text',
' \\includegraphics[width=0.5\\linewidth]{path/to/image}'
)
// move the cursor out of the figure
cy.get('@third-line').type('{DownArrow}{DownArrow}{DownArrow}{DownArrow}')
// Should be removed from dom when line is hidden
cy.get('.cm-content').should(
'not.contain.text',
'\\includegraphics[width=0.5\\linewidth]{path/to/image}'
)
cy.get('img.ol-cm-graphics').should('have.attr', 'src', 'path/to/image')
})
it('marks lines as figure environments', function () {
// inside the figure
cy.get('@second-line').should('have.class', 'ol-cm-environment-figure')
// outside the figure
cy.get('.cm-line')
.eq(6)
.should('not.have.class', 'ol-cm-environment-figure')
})
it('marks environment has centered when it has \\centering command', function () {
// inside the figure
cy.get('@third-line').should('have.class', 'ol-cm-environment-centered')
// outside the figure
cy.get('.cm-line')
.eq(6)
.should('not.have.class', 'ol-cm-environment-centered')
// the line containing \centering
cy.get('@second-line')
.should('have.text', ' \\centering')
.should('have.class', 'ol-cm-environment-centered')
cy.get('@second-line').type('{Backspace}')
cy.get('@second-line')
.should('have.text', ' \\centerin')
.should('not.have.class', 'ol-cm-environment-centered')
})
})
describe('verbatim environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}verbatim')
cy.get('@first-line').type('{Enter}test') // end with cursor in content
})
it('marks lines as verbatim environments', function () {
// inside the environment
cy.get('@second-line').should('have.class', 'ol-cm-environment-verbatim')
// outside the environment
cy.get('.cm-line')
.eq(4)
.should('not.have.class', 'ol-cm-environment-verbatim')
// move the cursor out of the environment
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' test')
})
})
describe('lstlisting environments', function () {
beforeEach(function () {
cy.get('@first-line').type('\\begin{{}lstlisting')
cy.get('@first-line').type('{Enter}test') // end with cursor in content
})
it('marks lines as lstlisting environments', function () {
// inside the environment
cy.get('@second-line').should(
'have.class',
'ol-cm-environment-lstlisting'
)
// outside the environment
cy.get('.cm-line')
.eq(4)
.should('not.have.class', 'ol-cm-environment-lstlisting')
// move the cursor out of the environment
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' test')
})
})
describe('Toolbar', function () {
describe('Formatting buttons highlighting', function () {
it('handles empty selections inside of bold', function () {
cy.get('@first-line').type('\\textbf{{}test}{LeftArrow}') // \textbf{test|}
cy.get('@toolbar-bold').should('have.class', 'active')
cy.get('@first-line').type('{LeftArrow}') // \textbf{tes|t}
cy.get('@toolbar-bold').should('have.class', 'active')
cy.get('@first-line').type('{LeftArrow}'.repeat(3)) // \textbf{|test}
cy.get('@toolbar-bold').should('have.class', 'active')
})
it('handles empty selections outside bold', function () {
cy.get('@first-line').type('\\textbf{{}test}')
cy.get('@toolbar-bold').should('not.have.class', 'active')
cy.get('@first-line').type('{LeftArrow}'.repeat(6))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('handles range selections inside bold', function () {
cy.get('@first-line').type('\\textbf{{}test}')
cy.get('@first-line').type('{LeftArrow}'.repeat(4))
cy.get('@first-line').type('{Shift}{RightArrow}{RightArrow}')
cy.get('@toolbar-bold').should('have.class', 'active')
})
it('handles range selections spanning bold', function () {
cy.get('@first-line').type('\\textbf{{}test} outside')
cy.get('@first-line').type('{LeftArrow}'.repeat(10))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(5))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('does not highlight bold when commands at selection ends are different', function () {
cy.get('@first-line').type('\\textbf{{}first} \\textbf{{}second}')
cy.get('@first-line').type('{LeftArrow}'.repeat(12))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(7))
cy.get('@toolbar-bold').should('not.have.class', 'active')
})
it('highlight when ends share common formatting ancestor', function () {
cy.get('@first-line').type(
'\\textbf{{}\\textit{{}first} \\textit{{}second}}'
)
cy.get('@first-line').type('{LeftArrow}'.repeat(13))
cy.get('@first-line').type('{Shift}' + '{RightArrow}'.repeat(7))
cy.get('@toolbar-bold').should('have.class', 'active')
})
})
})
describe('Beamer frames', function () {
it('hides markup', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-divider')
cy.get('.ol-cm-frame-title')
})
it('typesets title', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-frame-title').should('contain.html', 'Slide<br>title')
})
it('typesets math in title', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide $\\pi$}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.MathJax').should('contain.text', '$\\pi$')
})
it('typesets subtitle', function () {
cy.get('@first-line').type(
'\\begin{{}frame}{{}Slide title}{{}Slide subtitle}{Enter}\\end{{}frame}{Enter}'
)
cy.get('.ol-cm-frame-subtitle').should('have.html', 'Slide subtitle')
})
})
it('typesets \\maketitle', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title\\\\with $\\pi$}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-maketitle').should('have.class', 'MathJax')
cy.get('.ol-cm-title').should('contain.html', 'Document title<br>with')
cy.get('.ol-cm-author').should('have.text', 'Author')
cy.get('.ol-cm-preamble-widget').click()
const deleteLine =
'{command}{leftArrow}{shift}{command}{rightArrow}{backspace}'
// italic, bold and emph
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}formatted with \\textit{{}italic} \\textbf{{}bold} \\emph{{}emph}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'formatted with <i>italic</i> <b>bold</b> <em>emph</em>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}title\\\\ \\textbf{{}\\textit{{}\\emph{{}formated}}} \\textit{{}only italic}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'title<br> <b><i><em>formated</em></i></b> <i>only italic</i>'
)
// texttt command
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}title with \\texttt{{}command}}')
cy.get('.ol-cm-title').should(
'contain.html',
'title with <span class="ol-cm-command-texttt">command</span>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type(
'\\title{{}title with \\texttt{{}\\textbf{{}command}}}'
)
cy.get('.ol-cm-title').should(
'contain.html',
'title with <span class="ol-cm-command-texttt"><b>command</b></span>'
)
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}Title with \\& ampersands}')
cy.get('.ol-cm-title').should('contain.html', 'Title with &amp; ampersands')
// unsupported command
cy.get('@second-line').type(deleteLine)
cy.get('@second-line').type('\\title{{}My \\LaTeX{{}} document}')
cy.get('.ol-cm-title').should('contain.html', 'My \\LaTeX{} document')
})
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.} ')
})
it('should show document preamble', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-preamble-line').eq(0).should('contain', '\\author{Author}')
cy.get('.ol-cm-preamble-line')
.eq(1)
.should('contain', '\\title{Document title}')
cy.get('.ol-cm-preamble-line').eq(2).should('contain', '\\begin{document}')
cy.get('.ol-cm-preamble-line').eq(3).should('not.exist')
})
it('should exclude maketitle from preamble extents if nested in another environment', function () {
cy.get('@first-line').type(
[
'\\author{{}Author}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\begin{{}frame}{{}Foo}',
'\\maketitle',
'\\end{{}frame}',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-preamble-line').should('have.length', 3)
})
it('should show multiple authors', function () {
cy.get('@first-line').type(
[
'\\author{{}Author \\and Author2}',
'\\author{{}Author3}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-authors .ol-cm-author').should('have.length', 3)
})
it('should update authors', function () {
cy.get('@first-line').type(
[
'\\author{{}Author \\and Author2}',
'\\author{{}Author3}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-preamble-widget').should('have.length', 1)
cy.get('.ol-cm-preamble-widget').click()
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-author').eq(0).should('contain', 'Author')
cy.get('.ol-cm-author').eq(1).should('contain', 'Author2')
cy.get('.ol-cm-author').eq(2).should('contain', 'Author3')
cy.get('.ol-cm-author').eq(0).click()
cy.get('.ol-cm-preamble-line').eq(0).type('{leftarrow}{backspace}New')
cy.get('.ol-cm-author').eq(1).should('contain', 'AuthorNew')
// update author without changing node from/to coordinates
cy.get('.ol-cm-author').eq(0).click()
cy.get('.ol-cm-preamble-line').eq(0).type('{leftarrow}{shift}{leftarrow}X')
cy.get('.ol-cm-author').eq(1).should('contain', 'AuthorNeX')
})
it('should ignore some commands in author', function () {
cy.get('@first-line').type(
[
'\\author{{}Author with \\corref{{}cor1} and \\fnref{{}label2} in the name}',
'\\title{{}Document title}',
'\\begin{{}document}',
'\\maketitle',
'\\end{{}document}',
'',
].join('{Enter}')
)
cy.get('.ol-cm-authors').should('have.length', 1)
cy.get('.ol-cm-author').should(
'contain.html',
'Author with and in the name'
)
})
describe('decorates color commands', function () {
it('decorates textcolor', function () {
cy.get('@first-line').type('\\textcolor{{}red}{{}foo}')
cy.get('.ol-cm-textcolor')
.should('have.length', 1)
.should('have.text', 'foo')
.should('have.attr', 'style', 'color: rgb(255,0,0)')
})
it('decorates colorbox', function () {
cy.get('@first-line').type('\\colorbox{{}yellow}{{}foo}')
cy.get('.ol-cm-colorbox')
.should('have.length', 1)
.should('have.text', 'foo')
.should('have.attr', 'style', 'background-color: rgb(255,255,0)')
})
})
describe('handling of special characters', function () {
it('decorates a tilde with a non-breaking space', function () {
cy.get('@first-line').type('Test~test')
cy.get('@first-line').should('have.text', 'Test\xa0test')
})
it('decorates a backslash-prefixed tilde with a tilde', function () {
cy.get('@first-line').type('Test\\~test')
cy.get('@first-line').should('have.text', 'Test~test')
})
it('decorates a backslash-prefixed dollar sign with a dollar sign', function () {
cy.get('@first-line').type('\\$5.00')
cy.get('@first-line').should('have.text', '$5.00')
cy.get('.ol-cm-character').should('have.length', 1)
})
it('decorates line breaks', function () {
cy.get('@first-line').type('Test \\\\ test')
cy.get('@second-line').click()
cy.get('@first-line').should('have.text', 'Test ↩ test')
})
it('decorates spacing commands', function () {
cy.get('@first-line').type('\\thinspace')
cy.get('@second-line').click()
cy.get('@first-line')
.find('.ol-cm-space')
.should('have.attr', 'style', 'width: calc(0.166667em);')
})
it('decorates spacing symbols', function () {
cy.get('@first-line').type('\\,')
cy.get('@second-line').click()
cy.get('@first-line')
.find('.ol-cm-space')
.should('have.attr', 'style', 'width: calc(0.166667em);')
})
})
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('')
)
})
})
forEach(['quote', 'quotation', 'quoting', 'displayquote']).it(
'renders a %s environment',
function (environment) {
cy.get('@first-line').type(`\\begin{{}${environment}`)
cy.findAllByRole('listbox').should('have.length', 1)
cy.findByRole('listbox').contains(`\\begin{${environment}}`).click()
cy.get('@second-line').type('foo')
cy.get('.cm-content').should(
'have.text',
[`\\begin{${environment}}`, ' foo', `\\end{${environment}}`].join('')
)
cy.get('.cm-line').eq(4).click()
cy.get('.cm-content').should('have.text', ' foo')
}
)
it('invokes MathJax when math is written', function () {
cy.get('@first-line').type('foo $\\pi$ bar')
cy.get('@second-line').type(
'foo \n\\[\\epsilon{rightArrow}{rightArrow}\nbar'
)
cy.get('.MathJax').first().should('have.text', '\\pi')
cy.get('.MathJax').eq(1).should('have.text', '\\epsilon')
})
// TODO: \input
// TODO: Abstract
})

View File

@@ -0,0 +1,597 @@
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
import { EditorProviders } from '../../../helpers/editor-providers'
import { mockScope } from '../helpers/mock-scope'
import { metaKey } from '../helpers/meta-key'
import { docId } from '../helpers/mock-doc'
import { activeEditorLine } from '../helpers/active-editor-line'
import { TestContainer } from '../helpers/test-container'
import customLocalStorage from '@/infrastructure/local-storage'
import { OnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { FC } from 'react'
describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('deletes selected text on Backspace', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('this is some text')
cy.get('@line').should('have.text', 'this is some text')
cy.get('@line').type('{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}')
cy.get('@line').type('{backspace}')
cy.get('@line').should('have.text', 'this is some ')
})
it('renders client-side lint annotations in the gutter', function () {
const scope = mockScope()
const userSettings = { syntaxValidation: true }
cy.clock()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.tick(1000)
cy.clock().invoke('restore')
// TODO: aria role/label for gutter markers?
cy.get('.cm-lint-marker-error').should('have.length', 2)
cy.get('.cm-lint-marker-warning').should('have.length', 0)
})
it('renders annotations in the gutter', function () {
const scope = mockScope()
scope.pdf.logEntryAnnotations = {
[docId]: [
{
row: 20,
type: 'error',
text: 'Another error',
},
{
row: 19,
type: 'error',
text: 'An error',
},
{
row: 20,
type: 'warning',
text: 'A warning on the same line',
},
{
row: 25,
type: 'warning',
text: 'Another warning',
},
],
}
const userSettings = { syntaxValidation: false }
cy.clock()
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.tick(1000)
cy.clock().invoke('restore')
// TODO: aria role/label for gutter markers?
cy.get('.cm-lint-marker-error').should('have.length', 2)
cy.get('.cm-lint-marker-warning').should('have.length', 1)
})
it('renders code in an editor', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.contains('Your introduction goes here!')
})
it('does not indent when entering new line off non-empty line', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('foo{enter}')
activeEditorLine().should('have.text', '')
})
it('indents automatically when using snippet', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('\\begin{{}itemiz')
cy.findAllByRole('listbox').contains('\\begin{itemize}').click()
activeEditorLine().invoke('text').should('match', /^ {4}/)
})
it('keeps indentation when going to a new line', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
// Single indentation
cy.get('@line').trigger('keydown', { key: 'Tab' })
cy.get('@line').type('{enter}')
activeEditorLine().should('have.text', ' ')
// Double indentation
activeEditorLine().trigger('keydown', { key: 'Tab' }).type('{enter}')
activeEditorLine().should('have.text', ' ')
})
it('renders cursor highlights', function () {
const scope = mockScope()
const value = {
onlineUsers: {},
onlineUserCursorHighlights: {
[docId]: [
{
label: 'Test User',
cursor: { row: 10, column: 5 },
hue: 150,
},
{
label: 'Another User',
cursor: { row: 7, column: 2 },
hue: 50,
},
{
label: 'Starter User',
cursor: { row: 0, column: 0 },
hue: 0,
},
],
},
onlineUsersArray: [],
onlineUsersCount: 3,
}
const OnlineUsersProvider: FC = ({ children }) => {
return (
<OnlineUsersContext.Provider value={value}>
{children}
</OnlineUsersContext.Provider>
)
}
cy.mount(
<TestContainer>
<EditorProviders scope={scope} providers={{ OnlineUsersProvider }}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.ol-cm-cursorHighlight').should('have.length', 3)
})
it('does not allow typing to the document in read-only mode', function () {
const scope = mockScope()
scope.permissionsLevel = 'readOnly'
scope.permissions.write = false
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Handling the thrown error on failing to type text
cy.on('fail', error => {
if (error.message.includes('it requires a valid typeable element')) {
return
}
throw error
})
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type('text')
cy.get('@line').should('not.contain.text', 'text')
})
it('highlights matching brackets', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).click()
const pairs = ['()', '[]', '{}']
pairs.forEach(pair => {
activeEditorLine().type(pair).as('line')
cy.get('@line').find('.cm-matchingBracket')
cy.get('@line').type('{enter}')
})
})
it('folds code', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// select foldable line
cy.get('.cm-line').eq(9).as('line')
cy.get('@line').click()
const testUnfoldedState = () => {
cy.get('.cm-gutterElement').eq(11).should('have.text', '11')
cy.get('.cm-gutterElement').eq(12).should('have.text', '12')
}
const testFoldedState = () => {
cy.get('.cm-gutterElement').eq(11).should('have.text', '13')
cy.get('.cm-gutterElement').eq(12).should('have.text', '14')
}
testUnfoldedState()
// Fold
cy.get('span[title="Fold line"]').eq(1).click()
testFoldedState()
// Unfold
cy.get('span[title="Unfold line"]').eq(1).click()
testUnfoldedState()
})
it('save file with `:w` command in vim mode', function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptCompile()
const scope = mockScope()
const userSettings = { mode: 'vim' }
cy.mount(
<TestContainer>
<EditorProviders scope={scope} userSettings={userSettings}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Compile on initial load
cy.waitForCompile()
cy.interceptCompile()
// put the cursor on a blank line to type in
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('.cm-vim-panel').should('have.length', 0)
cy.get('@line').type(':')
cy.get('.cm-vim-panel').should('have.length', 1)
cy.get('.cm-vim-panel input').type('w')
cy.get('.cm-vim-panel input').type('{enter}')
// Compile after save
cy.waitForCompile()
})
it('search and replace text', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type(
'{enter}text_to_find{enter}abcde 1{enter}abcde 2{enter}abcde 3{enter}ABCDE 4{enter}'
)
// select text `text_to_find`
cy.get('.cm-line').eq(17).as('lineToFind')
cy.get('@lineToFind').dblclick()
// search panel is not displayed
cy.findByRole('search').should('have.length', 0)
cy.get('@lineToFind').type(`{${metaKey}+f}`)
// search panel is displayed
cy.findByRole('search').should('have.length', 1)
cy.findByRole('textbox', { name: 'Find' }).as('search-input')
cy.findByRole('textbox', { name: 'Replace' }).as('replace-input')
cy.get('@search-input')
// search input should be focused
.should('be.focused')
// search input's value should be set to the selected text
.should('have.value', 'text_to_find')
cy.get('@search-input').clear()
cy.get('@search-input').type('abcde')
cy.findByRole('button', { name: 'next' }).as('next-btn')
cy.findByRole('button', { name: 'previous' }).as('previous-btn')
// shows the number of matches
cy.contains(`1 of 4`)
for (let i = 4; i; i--) {
// go to previous occurrence
cy.get('@previous-btn').click()
// shows the number of matches
cy.contains(`${i} of 4`)
}
for (let i = 1; i <= 4; i++) {
// shows the number of matches
cy.contains(`${i} of 4`)
// go to next occurrence
cy.get('@next-btn').click()
}
// roll round to 1
cy.contains(`1 of 4`)
// matches case
cy.contains('Aa').click()
cy.get('@search-input').clear()
cy.get('@search-input').type('ABCDE')
cy.get('.cm-searchMatch-selected').should('contain.text', 'ABCDE')
cy.get('@search-input').clear()
cy.contains('Aa').click()
// matches regex
cy.contains('[.*]').click()
cy.get('@search-input').type('\\\\author\\{{}\\w+\\}')
cy.get('.cm-searchMatch-selected').should('contain.text', '\\author{You}')
cy.contains('[.*]').click()
cy.get('@search-input').clear()
cy.get('.cm-searchMatch-selected').should('not.exist')
// replace
cy.get('@search-input').type('abcde 1')
cy.get('@replace-input').type('test 1')
cy.findByRole('button', { name: 'Replace' }).click()
cy.get('.cm-line')
.eq(18)
.should('contain.text', 'test 1')
.should('not.contain.text', 'abcde')
// replace all
cy.get('@search-input').clear()
cy.get('@search-input').type('abcde')
cy.get('@replace-input').clear()
cy.get('@replace-input').type('test')
cy.findByRole('button', { name: /replace all/i }).click()
cy.get('@search-input').clear()
cy.get('@replace-input').clear()
cy.should('not.contain.text', 'abcde')
// replace all within selection
cy.get('@search-input').clear()
cy.get('@search-input').type('contentLine')
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 100')
cy.get('.cm-line').eq(27).as('contentLine')
cy.get('@contentLine').should('contain.text', 'contentLine 0')
cy.get('@contentLine').click()
cy.get('@contentLine').type('{shift}{downArrow}{downArrow}{downArrow}')
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '1 of 3')
cy.get('@replace-input').clear()
cy.get('@replace-input').type('contentedLine')
cy.findByRole('button', { name: /replace all/i }).click()
cy.get('.cm-line:contains("contentedLine")').should('have.length', 3)
cy.findByLabelText('Within selection').click()
cy.get('.ol-cm-search-form-position').should('have.text', '2 of 97')
cy.get('@search-input').clear()
cy.get('@replace-input').clear()
// close the search form, to clear the stored query
cy.findByRole('button', { name: 'Close' }).click()
})
it('navigates in the search panel', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
// Open the search panel
cy.get('.cm-line').eq(16).as('line')
cy.get('@line').click()
cy.get('@line').type(`{${metaKey}+f}`)
cy.findByRole('search').within(() => {
cy.findByLabelText('Find').as('find-input')
cy.findByLabelText('Replace').as('replace-input')
cy.get('[type="checkbox"][name="caseSensitive"]').as('case-sensitive')
cy.get('[type="checkbox"][name="regexp"]').as('regexp')
cy.get('[type="checkbox"][name="wholeWord"]').as('whole-word')
cy.get('[type="checkbox"][name="withinSelection"]').as('within-selection')
cy.get('label').contains('Aa').as('case-sensitive-label')
cy.get('label').contains('[.*]').as('regexp-label')
cy.get('label').contains('W').as('whole-word-label')
cy.findByLabelText('Within selection').as('within-selection-label')
cy.findByRole('button', { name: 'Replace' }).as('replace')
cy.findByRole('button', { name: 'Replace All' }).as('replace-all')
cy.findByRole('button', { name: 'previous' }).as('find-previous')
cy.findByRole('button', { name: 'next' }).as('find-next')
cy.findByRole('button', { name: 'Close' }).as('close')
// Tab forwards...
cy.get('@find-input').should('be.focused').tab()
cy.get('@replace-input').should('be.focused').tab()
cy.get('@case-sensitive').should('be.focused').tab()
cy.get('@regexp').should('be.focused').tab()
cy.get('@whole-word').should('be.focused').tab()
cy.get('@within-selection').should('be.focused').tab()
cy.get('@find-previous').should('be.focused').tab()
cy.get('@find-next').should('be.focused').tab()
cy.get('@replace').should('be.focused').tab()
cy.get('@replace-all').should('be.focused').tab()
// ... then backwards
cy.get('@close').should('be.focused').tab({ shift: true })
cy.get('@replace-all').should('be.focused').tab({ shift: true })
cy.get('@replace').should('be.focused').tab({ shift: true })
cy.get('@find-next').should('be.focused').tab({ shift: true })
cy.get('@find-previous').should('be.focused').tab({ shift: true })
cy.get('@within-selection').should('be.focused').tab({ shift: true })
cy.get('@whole-word').should('be.focused').tab({ shift: true })
cy.get('@regexp').should('be.focused').tab({ shift: true })
cy.get('@case-sensitive').should('be.focused').tab({ shift: true })
cy.get('@replace-input').should('be.focused').tab({ shift: true })
cy.get('@find-input').should('be.focused')
for (const option of [
'@case-sensitive-label',
'@regexp-label',
'@whole-word-label',
'@within-selection-label',
]) {
// Toggle when clicked, then focus the search input
cy.get(option).click()
cy.get(option).should('have.class', 'checked')
cy.get('@find-input').should('be.focused')
// Toggle when clicked again, then focus the search input
cy.get(option).click()
cy.get(option).should('not.have.class', 'checked')
cy.get('@find-input').should('be.focused')
}
})
})
it('restores stored cursor and scroll position', function () {
const scope = mockScope()
customLocalStorage.setItem(`doc.position.${docId}`, {
cursorPosition: { row: 50, column: 5 },
firstVisibleLine: 45,
})
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
activeEditorLine()
.should('have.text', 'contentLine 29')
.should(() => {
const selection = window.getSelection() as Selection
expect(selection.isCollapsed).to.be.true
const rect = selection.getRangeAt(0).getBoundingClientRect()
expect(Math.round(rect.top)).to.be.gte(100)
expect(Math.round(rect.left)).to.be.gte(90)
})
})
})