first commit
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { expect, use } from 'chai'
|
||||
import { toggleRanges } from '../../../../../frontend/js/features/source-editor/commands/ranges'
|
||||
import { CodemirrorTestSession, viewHelpers } from '../helpers/codemirror'
|
||||
|
||||
use(viewHelpers)
|
||||
|
||||
const BOLD_COMMAND = toggleRanges('\\textbf')
|
||||
|
||||
describe('toggleRanges', function () {
|
||||
describe('when text outside of a command is selected', function () {
|
||||
it('wraps the selection in a command', function () {
|
||||
const cm = new CodemirrorTestSession(['this <is my> range'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('this \\textbf{<is my>} range')
|
||||
})
|
||||
|
||||
describe('when it is an empty selection', function () {
|
||||
it('inserts a wrapping command and keep cursor inside the argument', function () {
|
||||
const cm = new CodemirrorTestSession(['this is | my range'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('this is \\textbf{|} my range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when it is an empty selection before a command', function () {
|
||||
it('inserts a wrapping command and keep cursor inside the argument', function () {
|
||||
const cm = new CodemirrorTestSession(['this is |\\textbf{my range}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('this is \\textbf{|}\\textbf{my range}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when text inside a command is selected', function () {
|
||||
describe('if the whole command is selected', function () {
|
||||
it('removes the wrapping command', function () {
|
||||
const cm = new CodemirrorTestSession(['this \\textbf{<is my>} range'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('this <is my> range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('if the command is empty', function () {
|
||||
it('removes the command', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{|}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('|')
|
||||
})
|
||||
})
|
||||
|
||||
describe('if the selection is at the beginning of a wrapping command', function () {
|
||||
it('shifts the start of the command', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{<this is> my} range'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('this is\\textbf{ my} range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('if the selection is at the end of a wrapping command', function () {
|
||||
it('shifts the end of the command', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{this <is my>} range'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{this }<is my> range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('if the selection is in the middle of a wrapping command', function () {
|
||||
it('splits command in two with non-empty selection', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{this <is my> range}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{this }<is my>\\textbf{ range}')
|
||||
})
|
||||
|
||||
it('splits command in two with empty selection', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{this is | my range}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{this is }|\\textbf{ my range}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection spans between two wrapping commands', function () {
|
||||
it('joins the two commands into one', function () {
|
||||
const cm = new CodemirrorTestSession([
|
||||
'\\textbf{this <is} my \\textbf{ran>ge}',
|
||||
])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{this <is my ran>ge}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection spans across a wrapping command', function () {
|
||||
it('extends to the left', function () {
|
||||
const cm = new CodemirrorTestSession(['<this \\textbf{is my> range}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{<this is my> range}')
|
||||
})
|
||||
|
||||
it('extends to the right', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{this is <my} range>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{this is <my range>}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection includes more than content', function () {
|
||||
describe('when selection contains command', function () {
|
||||
it('still unbolds', function () {
|
||||
const cm = new CodemirrorTestSession(['<\\textbf{this is my range>}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<this is my range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection contains opening bracket', function () {
|
||||
it('still unbolds', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf<{this is my range>}'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<this is my range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection contains closing bracket', function () {
|
||||
it('still unbolds', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf{<this is my range}>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<this is my range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection contains both brackets', function () {
|
||||
it('still unbolds', function () {
|
||||
const cm = new CodemirrorTestSession(['\\textbf<{this is my range}>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<this is my range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when selection contains entire command', function () {
|
||||
it('still unbolds', function () {
|
||||
const cm = new CodemirrorTestSession(['<\\textbf{this is my range}>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<this is my range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when toggling outer command', function () {
|
||||
it('it functions on the outer command', function () {
|
||||
const cm = new CodemirrorTestSession([
|
||||
'\\textbf{\\textit{<this is my range>}}',
|
||||
])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('<\\textit{this is my range}>')
|
||||
})
|
||||
|
||||
it('prevents breaking commands', function () {
|
||||
const cm = new CodemirrorTestSession([
|
||||
'\\textbf{\\textit{this <is} my} range>',
|
||||
])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{\\textit{this <is} my} range>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when range is after a command', function () {
|
||||
it('still formats list items', function () {
|
||||
const cm = new CodemirrorTestSession([
|
||||
'\\begin{itemize}',
|
||||
' \\item <My item>',
|
||||
'\\end{itemize}',
|
||||
])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(2).to.equal(' \\item \\textbf{<My item>}')
|
||||
})
|
||||
|
||||
it('still formats after command', function () {
|
||||
const cm = new CodemirrorTestSession(['\\noindent <My paragraph>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\noindent \\textbf{<My paragraph>}')
|
||||
})
|
||||
|
||||
it('still formats after unknown command with arguments', function () {
|
||||
const cm = new CodemirrorTestSession(['\\foo{test}<My paragraph>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\foo{test}\\textbf{<My paragraph>}')
|
||||
})
|
||||
|
||||
it('still formats after known command with arguments', function () {
|
||||
const cm1 = new CodemirrorTestSession(['\\cite{foo}<text>'])
|
||||
cm1.applyCommand(BOLD_COMMAND)
|
||||
expect(cm1).line(1).to.equal('\\cite{foo}\\textbf{<text>}')
|
||||
|
||||
const cm2 = new CodemirrorTestSession(['\\href{url}{title}<text>'])
|
||||
cm2.applyCommand(BOLD_COMMAND)
|
||||
expect(cm2).line(1).to.equal('\\href{url}{title}\\textbf{<text>}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('still formats text next to a command', function () {
|
||||
const cm = new CodemirrorTestSession(['<item>\\foo'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\textbf{item}\\foo')
|
||||
})
|
||||
|
||||
it('still formats part of a text next to command', function () {
|
||||
const cm = new CodemirrorTestSession(['hello <world>\\foo'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('hello \\textbf{world}\\foo')
|
||||
})
|
||||
|
||||
it('still formats command without arguments', function () {
|
||||
const cm = new CodemirrorTestSession(['\\item<\\foo>'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\item\\textbf{<\\foo>}')
|
||||
})
|
||||
|
||||
it('skips formatting if in the middle of two commands', function () {
|
||||
const cm = new CodemirrorTestSession(['\\f<oo\\b>ar'])
|
||||
cm.applyCommand(BOLD_COMMAND)
|
||||
expect(cm).line(1).to.equal('\\foo\\bar')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 & 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
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import sinon from 'sinon'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import {
|
||||
cursorPosition,
|
||||
restoreCursorPosition,
|
||||
} from '../../../../../frontend/js/features/source-editor/extensions/cursor-position'
|
||||
|
||||
const doc = `
|
||||
\\documentclass{article}
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
\\section{Introduction}
|
||||
Your introduction goes here!
|
||||
\\end{document}`
|
||||
|
||||
const mockDoc = () => {
|
||||
return {
|
||||
doc_id: 'test-doc',
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeMirror cursor position extension', function () {
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('stores cursor position when the view is destroyed', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 1, column: 1 },
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const setItem = sinon.spy(window.Storage.prototype, 'setItem')
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [cursorPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
|
||||
view.dispatch({
|
||||
selection: EditorSelection.cursor(50),
|
||||
})
|
||||
|
||||
view.destroy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setItem).to.have.been.calledWith(
|
||||
'doc.position.test-doc',
|
||||
JSON.stringify({
|
||||
cursorPosition: {
|
||||
row: 3,
|
||||
column: 6,
|
||||
},
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores cursor position', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
const getItem = sinon
|
||||
.stub(window.Storage.prototype, 'getItem')
|
||||
.callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 3, column: 5 },
|
||||
firstVisibleLine: 0,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [cursorPosition({ currentDoc })],
|
||||
}),
|
||||
})
|
||||
view.dispatch(restoreCursorPosition(view.state.doc, 'test-doc'))
|
||||
|
||||
expect(getItem).to.have.been.calledWith('doc.position.test-doc')
|
||||
|
||||
await waitFor(() => {
|
||||
const [range] = view.state.selection.ranges
|
||||
expect(range.head).to.eq(49)
|
||||
expect(range.anchor).to.eq(49)
|
||||
expect(range.empty).to.eq(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { foldEffect, foldState } from '@codemirror/language'
|
||||
import { EditorSelection, EditorState } from '@codemirror/state'
|
||||
import { DecorationSet, EditorView } from '@codemirror/view'
|
||||
import { expect } from 'chai'
|
||||
import { duplicateSelection } from '../../../../../frontend/js/features/source-editor/commands/ranges'
|
||||
|
||||
type Position = {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
function folds(foldRanges: DecorationSet) {
|
||||
const ranges: Position[] = []
|
||||
foldRanges.between(Number.MIN_VALUE, Number.MAX_VALUE, (from, to) => {
|
||||
ranges.push({ from, to })
|
||||
})
|
||||
return ranges
|
||||
}
|
||||
|
||||
describe('Line duplication command', function () {
|
||||
describe('For single selections', function () {
|
||||
describe('For cursor selection', function () {
|
||||
it('Cursor selection duplicates line downwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.cursor(0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nline2')
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('Preserves folded ranges', function () {
|
||||
const view = new EditorView({
|
||||
doc: '\\begin{itemize}\n\t\\item test\n\\end{itemize}',
|
||||
extensions: foldState,
|
||||
})
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
selection: EditorSelection.cursor(0),
|
||||
// Fold to \begin{itemize}...\end{itemize}
|
||||
effects: [foldEffect.of({ from: 15, to: 28 })],
|
||||
})
|
||||
)
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(folds(view.state.field(foldState))).to.deep.equal([
|
||||
{ from: 15, to: 28 },
|
||||
{ from: 57, to: 70 },
|
||||
])
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to
|
||||
.be.true
|
||||
})
|
||||
})
|
||||
describe('For range selections', function () {
|
||||
it('Duplicates line with a cursor downwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.cursor(0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nline2')
|
||||
})
|
||||
|
||||
it('Duplicates range forwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.range(0, 5),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1line1\nline2')
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 10)))
|
||||
.to.be.true
|
||||
})
|
||||
|
||||
it('Duplicates range backwards', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
selection: EditorSelection.range(5, 0),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1line1\nline2')
|
||||
expect(view.state.selection.ranges.length).to.equal(1)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 0)))
|
||||
.to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('For multiple selections', function () {
|
||||
it('Preserves folded ranges', function () {
|
||||
const doc =
|
||||
'\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}'
|
||||
const view = new EditorView({
|
||||
doc,
|
||||
extensions: [foldState, EditorState.allowMultipleSelections.of(true)],
|
||||
})
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.cursor(0),
|
||||
EditorSelection.cursor(43),
|
||||
]),
|
||||
effects: [
|
||||
foldEffect.of({ from: 15, to: 29 }),
|
||||
foldEffect.of({ from: 58, to: 72 }),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal(
|
||||
'\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}'
|
||||
)
|
||||
|
||||
expect(folds(view.state.field(foldState))).to.deep.equal([
|
||||
{ from: 15, to: 29 },
|
||||
{ from: 58, to: 72 },
|
||||
{ from: 101, to: 115 },
|
||||
{ from: 144, to: 158 },
|
||||
])
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(2)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to.be
|
||||
.true
|
||||
expect(view.state.selection.ranges[1].eq(EditorSelection.cursor(86))).to
|
||||
.be.true
|
||||
})
|
||||
|
||||
it('Duplicates all selections', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'line1\nline2',
|
||||
extensions: [EditorState.allowMultipleSelections.of(true)],
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.cursor(1),
|
||||
EditorSelection.range(7, 9),
|
||||
]),
|
||||
})
|
||||
|
||||
duplicateSelection(view)
|
||||
|
||||
expect(view.state.doc.toString()).to.equal('line1\nline1\nlinine2')
|
||||
|
||||
expect(view.state.selection.ranges.length).to.equal(2)
|
||||
expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(1))).to.be
|
||||
.true
|
||||
expect(view.state.selection.ranges[1].eq(EditorSelection.range(15, 17)))
|
||||
.to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect } from 'chai'
|
||||
import { EditorView, DecorationSet } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { buildDecorations } from '../../../../../frontend/js/features/source-editor/extensions/line-wrapping-indentation'
|
||||
|
||||
const basicDoc = `
|
||||
\\begin{document}
|
||||
Test
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineNoIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineWithIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
const docLongLineWithLotsOfIndentation = `
|
||||
\\begin{document}
|
||||
Test
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello
|
||||
|
||||
Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four
|
||||
|
||||
Hello
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
describe('line-wrapping-indentation', function () {
|
||||
describe('buildDecorations', function () {
|
||||
const _buildView = (doc: string) => {
|
||||
return new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const _toArray = (decorations: DecorationSet) => {
|
||||
const result = []
|
||||
const cursor = decorations.iter()
|
||||
while (cursor.value) {
|
||||
result.push({ from: cursor.from, to: cursor.to, value: cursor.value })
|
||||
cursor.next()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe('basic document', function () {
|
||||
it('should have no decorations', function () {
|
||||
const view = _buildView(basicDoc)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, no indentation', function () {
|
||||
it('should have no decorations', function () {
|
||||
const view = _buildView(docLongLineNoIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, with indentation', function () {
|
||||
it('should have a decoration', function () {
|
||||
const view = _buildView(docLongLineWithIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(1)
|
||||
|
||||
const decorationItem = _toArray(decorations)[0]
|
||||
expect(decorationItem.from).to.equal(23)
|
||||
expect(decorationItem.to).to.equal(23)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document with long lines, with lots of indentation', function () {
|
||||
it('should have a decoration', function () {
|
||||
const view = _buildView(docLongLineWithLotsOfIndentation)
|
||||
|
||||
const decorations = buildDecorations(view, 24)
|
||||
expect(decorations).to.exist
|
||||
expect(decorations.size).to.equal(4)
|
||||
|
||||
const decorationsArray = _toArray(decorations)
|
||||
const expectedPositions = [23, 265, 507, 758]
|
||||
|
||||
decorationsArray.forEach((item, index) => {
|
||||
expect(item.from).to.equal(expectedPositions[index])
|
||||
expect(item.to).to.equal(expectedPositions[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { EditorFacade } from '../../../../../frontend/js/features/source-editor/extensions/realtime'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
|
||||
describe('CodeMirror EditorFacade', function () {
|
||||
let state: EditorState, view: EditorView
|
||||
beforeEach(function () {
|
||||
state = EditorState.create()
|
||||
view = new EditorView({ state })
|
||||
})
|
||||
|
||||
it('should allow us to manipulate the CodeMirror document', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const text = 'basic test, nothing more'
|
||||
|
||||
editor.cmInsert(0, text)
|
||||
|
||||
expect(editor.getValue()).to.equal(text)
|
||||
|
||||
editor.cmDelete(0, 'b')
|
||||
|
||||
expect(editor.getValue()).to.equal(text.slice(1))
|
||||
})
|
||||
|
||||
it('should allow us to attach change listeners', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const listenerA = sinon.stub()
|
||||
const listenerB = sinon.stub()
|
||||
|
||||
editor.on('change', listenerA)
|
||||
editor.on('change', listenerB)
|
||||
|
||||
expect(listenerA).to.not.have.been.called
|
||||
expect(listenerB).to.not.have.been.called
|
||||
|
||||
const magicNumber = Math.random()
|
||||
editor.emit('change', magicNumber)
|
||||
|
||||
expect(listenerA).to.have.been.calledWith(magicNumber)
|
||||
expect(listenerB).to.have.been.calledWith(magicNumber)
|
||||
})
|
||||
|
||||
it('should attach to ShareJs document', function () {
|
||||
const editor = new EditorFacade(view)
|
||||
const text = 'something nice'
|
||||
const shareDoc = {
|
||||
on: sinon.stub(),
|
||||
getText: sinon.stub().returns(text),
|
||||
removeListener: sinon.stub(),
|
||||
detach_cm6: undefined,
|
||||
}
|
||||
|
||||
editor.cmInsert(0, text)
|
||||
|
||||
// @ts-ignore
|
||||
editor.attachShareJs(shareDoc)
|
||||
|
||||
expect(shareDoc.on.callCount).to.equal(2)
|
||||
expect(shareDoc.on).to.have.been.calledWith('insert')
|
||||
expect(shareDoc.on).to.have.been.calledWith('delete')
|
||||
|
||||
expect(shareDoc.detach_cm6).to.be.a('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import sinon from 'sinon'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import {
|
||||
restoreScrollPosition,
|
||||
scrollPosition,
|
||||
} from '../../../../../frontend/js/features/source-editor/extensions/scroll-position'
|
||||
|
||||
const doc = `
|
||||
\\documentclass{article}
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstract}
|
||||
\\section{Introduction}
|
||||
Your introduction goes here!
|
||||
\\end{document}`
|
||||
|
||||
const mockDoc = () => {
|
||||
return {
|
||||
doc_id: 'test-doc',
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeMirror scroll position extension', function () {
|
||||
beforeEach(function () {
|
||||
sinon.stub(HTMLElement.prototype, 'scrollHeight').returns(800)
|
||||
sinon.stub(HTMLElement.prototype, 'scrollWidth').returns(500)
|
||||
sinon.stub(HTMLElement.prototype, 'clientHeight').returns(200)
|
||||
sinon.stub(HTMLElement.prototype, 'clientWidth').returns(500)
|
||||
|
||||
sinon
|
||||
.stub(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.returns({ top: 100, left: 0, right: 500, bottom: 200 } as DOMRect)
|
||||
|
||||
// Range.getClientRects doesn't exist yet in jsdom
|
||||
window.Range.prototype.getClientRects = sinon.stub().returns([])
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
// @ts-ignore
|
||||
delete window.Range.prototype.getClientRects
|
||||
})
|
||||
|
||||
it('stores scroll position when the view is destroyed', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => {
|
||||
switch (key) {
|
||||
case 'doc.position.test-doc':
|
||||
return JSON.stringify({
|
||||
cursorPosition: { row: 2, column: 2 },
|
||||
firstVisibleLine: 5,
|
||||
})
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [scrollPosition({ currentDoc }, { visual: false })],
|
||||
}),
|
||||
})
|
||||
|
||||
const setItem = sinon.spy(window.Storage.prototype, 'setItem')
|
||||
fireEvent.scroll(view.scrollDOM, { target: { scrollTop: 10 } })
|
||||
|
||||
view.destroy()
|
||||
|
||||
const expected = JSON.stringify({
|
||||
cursorPosition: { row: 2, column: 2 },
|
||||
firstVisibleLine: 12,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setItem).to.have.been.calledWith('doc.position.test-doc', expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores scroll position', async function () {
|
||||
const currentDoc = mockDoc()
|
||||
|
||||
const getItem = sinon
|
||||
.stub(window.Storage.prototype, 'getItem')
|
||||
.callsFake(key => {
|
||||
switch (key) {
|
||||
case 'editor.position.test-doc':
|
||||
return JSON.stringify({ firstVisibleLine: 12 })
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [scrollPosition({ currentDoc }, { visual: false })],
|
||||
}),
|
||||
})
|
||||
view.dispatch(restoreScrollPosition())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getItem).to.have.been.calledWith('doc.position.test-doc')
|
||||
})
|
||||
|
||||
// TODO: scrollTop should be a higher value but requires more mocking
|
||||
// await waitFor(() => {
|
||||
// expect(view.scrollDOM.scrollTop).to.eq(0)
|
||||
// })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { WordCache } from '@/features/source-editor/extensions/spelling/cache'
|
||||
import { expect } from 'chai'
|
||||
import { Word } from '@/features/source-editor/extensions/spelling/spellchecker'
|
||||
|
||||
describe('WordCache', function () {
|
||||
describe('basic operations', function () {
|
||||
let cache: WordCache, lang: string
|
||||
beforeEach(function () {
|
||||
cache = new WordCache()
|
||||
lang = 'xx'
|
||||
})
|
||||
|
||||
it('should store values in cache', function () {
|
||||
let word = 'foo'
|
||||
expect(cache.get(lang, word)).to.not.exist
|
||||
cache.set(lang, word, true)
|
||||
expect(cache.get(lang, word)).to.equal(true)
|
||||
|
||||
word = 'bar'
|
||||
expect(cache.get(lang, word)).to.not.exist
|
||||
cache.set(lang, word, false)
|
||||
expect(cache.get(lang, word)).to.equal(false)
|
||||
})
|
||||
|
||||
it('should store words in separate languages', function () {
|
||||
const word = 'foo'
|
||||
const otherLang = 'zz'
|
||||
|
||||
cache.set(lang, word, true)
|
||||
expect(cache.get(lang, word)).to.equal(true)
|
||||
expect(cache.get(otherLang, word)).to.not.exist
|
||||
|
||||
cache.set(otherLang, word, false)
|
||||
expect(cache.get(lang, word)).to.equal(true)
|
||||
expect(cache.get(otherLang, word)).to.equal(false)
|
||||
})
|
||||
|
||||
it('should check words against cache', function () {
|
||||
cache.set(lang, 'foo', false)
|
||||
cache.set(lang, 'bar', true)
|
||||
cache.set(lang, 'baz', true)
|
||||
const wordsToCheck = [
|
||||
{ text: 'foo', from: 0 },
|
||||
{ text: 'baz', from: 1 },
|
||||
{ text: 'quux', from: 2 },
|
||||
{ text: 'foo', from: 3 },
|
||||
{ text: 'zaz', from: 4 },
|
||||
] as Word[]
|
||||
const result = cache.checkWords(lang, wordsToCheck)
|
||||
expect(result).to.have.keys('knownMisspelledWords', 'unknownWords')
|
||||
expect(result.knownMisspelledWords).to.deep.equal([
|
||||
{ text: 'foo', from: 0 },
|
||||
{ text: 'foo', from: 3 },
|
||||
])
|
||||
expect(result.unknownWords).to.deep.equal([
|
||||
{ text: 'quux', from: 2 },
|
||||
{ text: 'zaz', from: 4 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
getWordsFromLine,
|
||||
buildSpellCheckResult,
|
||||
Word,
|
||||
} from '@/features/source-editor/extensions/spelling/spellchecker'
|
||||
import { expect } from 'chai'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
|
||||
const extensions = [new LanguageSupport(LaTeXLanguage)]
|
||||
|
||||
describe('SpellChecker', function () {
|
||||
describe('getWordsFromLine', function () {
|
||||
let lang: string
|
||||
beforeEach(function () {
|
||||
/* Note: ignore the word 'test' */
|
||||
lang = 'en'
|
||||
})
|
||||
|
||||
it('should get words from a line', function () {
|
||||
const view = new EditorView({
|
||||
doc: 'Hello test one two',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = Array.from(getWordsFromLine(view, line, lang))
|
||||
expect(words).to.deep.equal([
|
||||
{ text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'test', from: 6, to: 10, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should get no words from an empty line', function () {
|
||||
const view = new EditorView({
|
||||
doc: ' ',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = Array.from(getWordsFromLine(view, line, lang))
|
||||
expect(words).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should ignore content of some commands in the text', function () {
|
||||
const view = new EditorView({
|
||||
doc: '\\usepackage[foo]{ bar } seven eight',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = Array.from(getWordsFromLine(view, line, lang))
|
||||
expect(words).to.deep.equal([
|
||||
{ text: 'seven', from: 24, to: 29, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'eight', from: 30, to: 35, lineNumber: 1, lang: 'en' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should ignore command names in the text', function () {
|
||||
const view = new EditorView({
|
||||
doc: '\\foo nine \\bar ten \\baz[]{}',
|
||||
extensions,
|
||||
})
|
||||
const line = view.state.doc.line(1)
|
||||
const words = Array.from(getWordsFromLine(view, line, lang))
|
||||
expect(words).to.deep.equal([
|
||||
{ text: 'nine', from: 5, to: 9, lineNumber: 1, lang: 'en' },
|
||||
{ text: 'ten', from: 15, to: 18, lineNumber: 1, lang: 'en' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSpellCheckResult', function () {
|
||||
it('should build an empty result', function () {
|
||||
const knownMisspelledWords: Word[] = []
|
||||
const unknownWords: Word[] = []
|
||||
const misspellings: { index: number }[] = []
|
||||
const result = buildSpellCheckResult(
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
expect(result).to.deep.equal({
|
||||
cacheAdditions: [],
|
||||
misspelledWords: [],
|
||||
})
|
||||
})
|
||||
it('should build a realistic result', function () {
|
||||
const _makeWord = (text: string) => {
|
||||
return new Word({
|
||||
text,
|
||||
from: 0,
|
||||
to: 0,
|
||||
lineNumber: 0,
|
||||
lang: 'xx',
|
||||
})
|
||||
}
|
||||
// We know this word is misspelled
|
||||
const knownMisspelledWords = [_makeWord('fff')]
|
||||
// These words we didn't know
|
||||
const unknownWords = [
|
||||
_makeWord('aaa'),
|
||||
_makeWord('bbb'),
|
||||
_makeWord('ccc'),
|
||||
_makeWord('ddd'),
|
||||
]
|
||||
// These are the suggestions we got back from the backend
|
||||
const misspellings = [{ index: 1 }, { index: 3 }]
|
||||
// Build the result structure
|
||||
const result = buildSpellCheckResult(
|
||||
knownMisspelledWords,
|
||||
unknownWords,
|
||||
misspellings
|
||||
)
|
||||
expect(result).to.have.keys('cacheAdditions', 'misspelledWords')
|
||||
// Check cache additions
|
||||
expect(result.cacheAdditions.map(([k, v]) => [k.text, v])).to.deep.equal([
|
||||
// Put these in cache as known misspellings
|
||||
['bbb', false],
|
||||
['ddd', false],
|
||||
// Put these in cache as known-correct
|
||||
['aaa', true],
|
||||
['ccc', true],
|
||||
])
|
||||
// Check misspellings
|
||||
expect(result.misspelledWords.map(w => w.text)).to.deep.equal([
|
||||
// Words in the payload that we now know were misspelled
|
||||
'bbb',
|
||||
'ddd',
|
||||
// Word we already knew was misspelled, preserved here
|
||||
'fff',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export const activeEditorLine = () => {
|
||||
// wait for the selection to be in the editor content DOM
|
||||
cy.window().then(win => {
|
||||
cy.get('.cm-content').should($el => {
|
||||
const contentNode = $el.get(0)
|
||||
const range = win.getSelection()?.getRangeAt(0)
|
||||
expect(range?.intersectsNode(contentNode)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
// find the closest line block ancestor of the selection
|
||||
return cy.window().then(win => {
|
||||
const activeNode = win.getSelection()?.focusNode
|
||||
|
||||
if (!activeNode) {
|
||||
return cy.wrap(null)
|
||||
}
|
||||
|
||||
// use the parent element if this is a node, e.g. text
|
||||
const activeElement = (
|
||||
'closest' in activeNode ? activeNode : activeNode.parentElement
|
||||
) as HTMLElement | undefined
|
||||
|
||||
return cy.wrap(activeElement?.closest('.cm-line'))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable no-dupe-class-members */
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
import { EditorSelection, Line, SelectionRange } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Assertion } from 'chai'
|
||||
import { LaTeXLanguage } from '../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
|
||||
|
||||
export class CodemirrorTestSession {
|
||||
public view: EditorView
|
||||
|
||||
constructor(content: string[] | string) {
|
||||
this.view = createView(content)
|
||||
}
|
||||
|
||||
insert(content: string): void {
|
||||
this.view.dispatch(
|
||||
this.view.state.changeByRange(range => {
|
||||
const changeDescription = [
|
||||
{
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
insert: content,
|
||||
},
|
||||
]
|
||||
|
||||
const changes = this.view.state.changes(changeDescription)
|
||||
|
||||
return {
|
||||
range: EditorSelection.cursor(range.head).map(changes),
|
||||
changes,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
insertAt(position: number, content: string) {
|
||||
const changes = [{ from: position, insert: content }]
|
||||
this.view.dispatch({
|
||||
changes,
|
||||
selection: this.view.state.selection.map(
|
||||
this.view.state.changes(changes),
|
||||
1
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
insertAtLine(line: number, offset: number, content: string): void
|
||||
insertAtLine(line: number, content: string): void
|
||||
insertAtLine(
|
||||
lineNumber: number,
|
||||
offsetOrContent: string | number,
|
||||
content?: string
|
||||
) {
|
||||
const line = this.view.state.doc.line(lineNumber)
|
||||
if (typeof offsetOrContent === 'string' && typeof content === 'string') {
|
||||
throw new Error(
|
||||
'If a third argument is provided, the second must be an integer'
|
||||
)
|
||||
}
|
||||
// Insert at end of line
|
||||
if (typeof offsetOrContent === 'string') {
|
||||
content = offsetOrContent
|
||||
offsetOrContent = line.to
|
||||
}
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('content must be provided to insertAtLine')
|
||||
}
|
||||
|
||||
if (offsetOrContent < line.from || offsetOrContent > line.to) {
|
||||
throw new Error('Offset is outside the range of the line')
|
||||
}
|
||||
this.insertAt(line.from + offsetOrContent, content)
|
||||
}
|
||||
|
||||
delete(position: number, length: number) {
|
||||
this.view.dispatch({
|
||||
changes: [{ from: position - length, to: position }],
|
||||
})
|
||||
}
|
||||
|
||||
applyCommand(command: (view: EditorView) => any) {
|
||||
return command(this.view)
|
||||
}
|
||||
|
||||
setCursor(position: number): void
|
||||
setCursor(line: number, offset: number): void
|
||||
setCursor(positionOrLine: number, offset?: number) {
|
||||
if (offset !== undefined) {
|
||||
const line = this.view.state.doc.line(positionOrLine)
|
||||
positionOrLine = line.from + offset
|
||||
}
|
||||
this.view.dispatch({
|
||||
selection: EditorSelection.cursor(positionOrLine),
|
||||
})
|
||||
}
|
||||
|
||||
setSelection(selection: EditorSelection) {
|
||||
this.view.dispatch({
|
||||
selection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const latex = new LanguageSupport(LaTeXLanguage)
|
||||
function createView(content: string[] | string): EditorView {
|
||||
if (Array.isArray(content)) {
|
||||
content = content.join('\n')
|
||||
}
|
||||
return new EditorView({
|
||||
doc: stripSelectionMarkers(content),
|
||||
selection: createSelections(content) ?? EditorSelection.cursor(0),
|
||||
extensions: [latex],
|
||||
})
|
||||
}
|
||||
|
||||
function stripSelectionMarkers(content: string) {
|
||||
return content.replaceAll(/[<|>]/g, '')
|
||||
}
|
||||
|
||||
function hasSelectionMarkers(content: string) {
|
||||
return !!content.match(/[<|>]/g)
|
||||
}
|
||||
|
||||
function createSelections(content: string, offset = 0) {
|
||||
const selections = []
|
||||
let index = 0
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === '|') {
|
||||
selections.push(EditorSelection.cursor(index + offset))
|
||||
}
|
||||
if (content[i] === '<') {
|
||||
// find end
|
||||
const startOfRange = index
|
||||
let foundEnd = false
|
||||
for (++i; i < content.length; ++i) {
|
||||
if (content[i] === '|') {
|
||||
throw new Error(
|
||||
"Invalid cursor indicator '|' within a range started with '<'"
|
||||
)
|
||||
}
|
||||
if (content[i] === '<') {
|
||||
throw new Error(
|
||||
"Invalid start range indicator '<' inside another range"
|
||||
)
|
||||
}
|
||||
if (content[i] === '>') {
|
||||
foundEnd = true
|
||||
selections.push(
|
||||
EditorSelection.range(startOfRange + offset, index + offset)
|
||||
)
|
||||
break
|
||||
}
|
||||
index++
|
||||
}
|
||||
if (!foundEnd) {
|
||||
throw new Error("Missing end range indicator '>'")
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
if (selections.length) {
|
||||
return EditorSelection.create(selections)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace Chai {
|
||||
interface Assertion {
|
||||
line(lineNumber: number): Assertion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function viewHelpers(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) {
|
||||
utils.addMethod(
|
||||
chai.Assertion.prototype,
|
||||
'line',
|
||||
function getLine(this: Chai.Assertion, line: number) {
|
||||
const object = utils.flag(this, 'object')
|
||||
new Assertion(object).to.be.instanceOf(CodemirrorTestSession)
|
||||
const testSession = object as CodemirrorTestSession
|
||||
const lineInEditor = testSession.view.state.doc.line(line)
|
||||
utils.flag(this, 'object', lineInEditor.text)
|
||||
utils.flag(this, 'cmSession', testSession)
|
||||
utils.flag(this, 'line', lineInEditor)
|
||||
}
|
||||
)
|
||||
utils.overwriteMethod(chai.Assertion.prototype, 'equal', (_super: any) => {
|
||||
return function newEqual(
|
||||
this: Chai.Assertion,
|
||||
value: string,
|
||||
requireSelections?: boolean
|
||||
) {
|
||||
const session = utils.flag(this, 'cmSession') as
|
||||
| CodemirrorTestSession
|
||||
| undefined
|
||||
utils.flag(this, 'cmSession', null)
|
||||
const line = utils.flag(this, 'line') as Line | undefined
|
||||
utils.flag(this, 'line', null)
|
||||
|
||||
if (!session || !line) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
return _super.apply(this, arguments)
|
||||
}
|
||||
|
||||
const lineContent = stripSelectionMarkers(value)
|
||||
|
||||
if (requireSelections === undefined) {
|
||||
requireSelections = hasSelectionMarkers(value)
|
||||
}
|
||||
|
||||
// We can now check selections as well
|
||||
const selections = createSelections(value, line.from)
|
||||
const contentAssertion = new Assertion(line.text)
|
||||
utils.transferFlags(this, contentAssertion)
|
||||
contentAssertion.to.equal(lineContent)
|
||||
|
||||
if (selections) {
|
||||
const selectionAssertion = new Assertion(
|
||||
session.view.state.selection.ranges
|
||||
)
|
||||
utils.transferFlags(this, selectionAssertion, false)
|
||||
for (const rangeToMatch of selections.ranges) {
|
||||
selectionAssertion.satisfies(
|
||||
(ranges: SelectionRange[]) =>
|
||||
ranges.some(
|
||||
possibleMatch =>
|
||||
possibleMatch.eq(rangeToMatch) ||
|
||||
// Allow reverse selections as well, as we don't syntactically
|
||||
// distinguish them
|
||||
EditorSelection.range(
|
||||
possibleMatch.to,
|
||||
possibleMatch.from
|
||||
).eq(rangeToMatch)
|
||||
),
|
||||
`Selections [${session.view.state.selection.ranges
|
||||
.map(range => `{ from: ${range.from}, to: ${range.to}}`)
|
||||
.join(', ')}] did not include selection {from: ${
|
||||
rangeToMatch.from
|
||||
}, to: ${rangeToMatch.to}}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
|
||||
export const metaKey = isMac ? 'meta' : 'ctrl'
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ShareDoc } from '../../../../../types/share-doc'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
export const docId = 'test-doc'
|
||||
|
||||
export function mockDocContent(content: string) {
|
||||
return `
|
||||
\\documentclass{article}
|
||||
|
||||
\\title{Your Paper}
|
||||
\\author{You}
|
||||
|
||||
\\begin{document}
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
Your abstract.
|
||||
\\end{abstracts}
|
||||
|
||||
\\section{Introduction}
|
||||
|
||||
Your introduction goes here!
|
||||
|
||||
\\section{Results}
|
||||
|
||||
Your results go here! \\cite{foo}
|
||||
|
||||
${content}
|
||||
|
||||
\\end{document}`
|
||||
}
|
||||
|
||||
const contentLines = Array.from(Array(100), (e, i) => `contentLine ${i}`)
|
||||
const defaultContent = mockDocContent(contentLines.join('\n'))
|
||||
|
||||
const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength
|
||||
|
||||
class MockShareDoc extends EventEmitter {
|
||||
constructor(public text: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
getText() {
|
||||
return this.text
|
||||
}
|
||||
|
||||
insert() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
del() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export const mockDoc = (content = defaultContent) => {
|
||||
const mockShareJSDoc: ShareDoc = new MockShareDoc(content)
|
||||
|
||||
return {
|
||||
doc_id: docId,
|
||||
getSnapshot: () => {
|
||||
return content
|
||||
},
|
||||
attachToCM6: (cm6: any) => {
|
||||
cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH)
|
||||
},
|
||||
detachFromCM6: () => {
|
||||
// Do nothing
|
||||
},
|
||||
on: () => {
|
||||
// Do nothing
|
||||
},
|
||||
off: () => {
|
||||
// Do nothing
|
||||
},
|
||||
ranges: {
|
||||
changes: [],
|
||||
comments: [],
|
||||
getIdSeed: () => '123',
|
||||
setIdSeed: () => {},
|
||||
getTrackedDeletesLength: () => 0,
|
||||
getDirtyState: () => ({
|
||||
comment: {
|
||||
moved: {},
|
||||
removed: {},
|
||||
added: {},
|
||||
},
|
||||
change: {
|
||||
moved: {},
|
||||
removed: {},
|
||||
added: {},
|
||||
},
|
||||
}),
|
||||
resetDirtyState: () => {},
|
||||
},
|
||||
setTrackChangesIdSeeds: () => {},
|
||||
getTrackingChanges: () => true,
|
||||
setTrackingChanges: () => {},
|
||||
getInflightOp: () => null,
|
||||
getPendingOp: () => null,
|
||||
hasBufferedOps: () => false,
|
||||
leaveAndCleanUpPromise: () => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { docId, mockDoc } from './mock-doc'
|
||||
import { sleep } from '../../../helpers/sleep'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
|
||||
export const rootFolderId = '012345678901234567890123'
|
||||
export const figuresFolderId = '123456789012345678901234'
|
||||
export const figureId = '234567890123456789012345'
|
||||
export const mockScope = (content?: string) => {
|
||||
return {
|
||||
editor: {
|
||||
sharejs_doc: mockDoc(content),
|
||||
open_doc_name: 'test.tex',
|
||||
open_doc_id: docId,
|
||||
showVisual: false,
|
||||
wantTrackChanges: false,
|
||||
},
|
||||
pdf: {
|
||||
logEntryAnnotations: {},
|
||||
},
|
||||
project: {
|
||||
_id: 'test-project',
|
||||
name: 'Test Project',
|
||||
spellCheckLanguage: 'en',
|
||||
rootFolder: [
|
||||
{
|
||||
_id: rootFolderId,
|
||||
name: 'rootFolder',
|
||||
docs: [
|
||||
{
|
||||
_id: docId,
|
||||
name: 'test.tex',
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
{
|
||||
_id: figuresFolderId,
|
||||
name: 'figures',
|
||||
docs: [
|
||||
{
|
||||
_id: 'fake-nested-doc-id',
|
||||
name: 'foo.tex',
|
||||
},
|
||||
],
|
||||
folders: [],
|
||||
fileRefs: [
|
||||
{
|
||||
_id: figureId,
|
||||
name: 'frog.jpg',
|
||||
hash: '42',
|
||||
},
|
||||
{
|
||||
_id: 'fake-figure-id',
|
||||
name: 'unicorn.png',
|
||||
hash: '43',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fileRefs: [],
|
||||
},
|
||||
] as Folder[],
|
||||
features: {
|
||||
trackChanges: true,
|
||||
},
|
||||
trackChangesState: {},
|
||||
members: [],
|
||||
},
|
||||
permissions: {
|
||||
comment: true,
|
||||
trackedWrite: true,
|
||||
write: true,
|
||||
},
|
||||
ui: {
|
||||
reviewPanelOpen: false,
|
||||
},
|
||||
toggleReviewPanel: cy.stub(),
|
||||
toggleTrackChangesForEveryone: cy.stub(),
|
||||
refreshResolvedCommentsDropdown: cy.stub(() => sleep(1000)),
|
||||
onlineUserCursorHighlights: {},
|
||||
permissionsLevel: 'owner',
|
||||
$on: cy.stub().log(false),
|
||||
$broadcast: cy.stub().log(false),
|
||||
$emit: cy.stub().log(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FC, ComponentProps, Suspense } from 'react'
|
||||
|
||||
const style = { width: 785, height: 785 }
|
||||
|
||||
export const TestContainer: FC<ComponentProps<'div'>> = ({
|
||||
children,
|
||||
...rest
|
||||
}) => (
|
||||
<div style={style} {...rest}>
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
import { expect } from 'chai'
|
||||
import { EditorState, Text } from '@codemirror/state'
|
||||
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
|
||||
import {
|
||||
ensureSyntaxTree,
|
||||
foldNodeProp,
|
||||
LanguageSupport,
|
||||
} from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
const latex = new LanguageSupport(LaTeXLanguage)
|
||||
|
||||
const makeView = (lines: string[]): EditorView => {
|
||||
const text = Text.of(lines)
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: [latex],
|
||||
}),
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
type Fold = { from: number; to: number }
|
||||
|
||||
const _getFolds = (view: EditorView) => {
|
||||
const ranges: Fold[] = []
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length)
|
||||
if (!tree) {
|
||||
throw new Error("Couldn't get Syntax Tree")
|
||||
}
|
||||
tree.iterate({
|
||||
enter: nodeRef => {
|
||||
const prop = nodeRef.type.prop(foldNodeProp)
|
||||
if (prop) {
|
||||
const hasFold = prop(nodeRef.node, view.state)
|
||||
if (hasFold) {
|
||||
ranges.push({ from: hasFold.from, to: hasFold.to })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return ranges
|
||||
}
|
||||
|
||||
describe('CodeMirror LaTeX-folding', function () {
|
||||
describe('With empty document', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should not produce any folds', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds).to.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sectioning command folding', function () {
|
||||
describe('with no foldable sections', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['hello', 'test']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should not produce any folds', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds).to.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
describe('with one foldable section', function () {
|
||||
let view: EditorView, content: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
content = ['hello', '\\section{one}', 'a', 'b', 'c']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should produce one fold', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
})
|
||||
|
||||
it('should fold from the section line to last line', function () {
|
||||
const folds = _getFolds(view)
|
||||
const fold = folds[0]
|
||||
expect(view.state.doc.lineAt(fold.from).number).to.equal(2)
|
||||
expect(view.state.doc.lineAt(fold.to).number).to.equal(
|
||||
view.state.doc.lines
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with two foldable sections', function () {
|
||||
let view: EditorView, content: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'hello',
|
||||
'\\section{one}',
|
||||
'a',
|
||||
'b',
|
||||
'\\section{two}',
|
||||
'c',
|
||||
'd',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should produce two folds', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(2)
|
||||
expect(view.state.doc.lineAt(folds[0].from).number).to.equal(2)
|
||||
expect(view.state.doc.lineAt(folds[0].to).number).to.equal(4)
|
||||
expect(view.state.doc.lineAt(folds[1].from).number).to.equal(5)
|
||||
expect(view.state.doc.lineAt(folds[1].to).number).to.equal(
|
||||
view.state.doc.lines
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with realistic nesting', function () {
|
||||
let view: EditorView, content: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'hello',
|
||||
'\\chapter{1}',
|
||||
' a',
|
||||
' \\section{1.1}',
|
||||
' a',
|
||||
' \\subsection{1.1.1}',
|
||||
' a',
|
||||
' \\section{1.2}',
|
||||
' a',
|
||||
' \\subsection{1.2.1}',
|
||||
' a',
|
||||
'\\chapter{2}',
|
||||
' a',
|
||||
' \\section{2.1}',
|
||||
' a',
|
||||
' \\section{2.2}',
|
||||
' a',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should produce many folds', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(8)
|
||||
|
||||
const foldDescriptions = folds.map(fold => {
|
||||
const fromLine = view.state.doc.lineAt(fold.from).number
|
||||
const toLine = view.state.doc.lineAt(fold.to).number
|
||||
return { fromLine, toLine }
|
||||
})
|
||||
|
||||
expect(foldDescriptions).to.deep.equal([
|
||||
{ fromLine: 2, toLine: 11 },
|
||||
{ fromLine: 4, toLine: 7 },
|
||||
{ fromLine: 6, toLine: 7 },
|
||||
{ fromLine: 8, toLine: 11 },
|
||||
{ fromLine: 10, toLine: 11 },
|
||||
{ fromLine: 12, toLine: 17 },
|
||||
{ fromLine: 14, toLine: 15 },
|
||||
{ fromLine: 16, toLine: 17 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Environment folding', function () {
|
||||
describe('with single environment', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\begin{foo}', 'content', '\\end{foo}']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold the environment', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
expect(folds).to.deep.equal([{ from: 11, to: 20 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with nested environment', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'\\begin{foo}',
|
||||
'\\begin{bar}',
|
||||
'content',
|
||||
'\\end{bar}',
|
||||
'\\end{foo}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold the environment', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(2)
|
||||
expect(folds).to.deep.equal([
|
||||
{ from: 11, to: 42 },
|
||||
{ from: 23, to: 32 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comment folding', function () {
|
||||
describe('with a single set of comments', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['Hello', '% {', 'this is folded', '% }', 'End']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold the region marked by comments', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
expect(folds).to.deep.equal([{ from: 9, to: 27 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with several sets of comments', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'Hello',
|
||||
'% {',
|
||||
'this is folded',
|
||||
'% }',
|
||||
'',
|
||||
'% {',
|
||||
'and this also',
|
||||
'% }',
|
||||
'End',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold both regions marked by comments', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(2)
|
||||
expect(folds).to.deep.equal([
|
||||
{ from: 9, to: 27 },
|
||||
{ from: 33, to: 50 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with nested sets of comments', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'Hello',
|
||||
'% {',
|
||||
'one',
|
||||
'% {',
|
||||
'two',
|
||||
'% {',
|
||||
'three',
|
||||
'% }',
|
||||
'two',
|
||||
'% }',
|
||||
'one',
|
||||
'% }',
|
||||
'End',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold all the regions marked by comments, with nesting', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(3)
|
||||
expect(folds).to.deep.equal([
|
||||
{ from: 9, to: 50 },
|
||||
{ from: 17, to: 42 },
|
||||
{ from: 25, to: 34 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with fold comment spanning entire document', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['% {', 'Hello', '% }']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
expect(folds).to.deep.equal([{ from: 3, to: 12 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with fold comment at start of document', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['% {', 'Hello', '% }', 'Test']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
expect(folds).to.deep.equal([{ from: 3, to: 12 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('with fold comment at end of document', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['Test', '% {', 'Hello', '% }']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should fold', function () {
|
||||
const folds = _getFolds(view)
|
||||
expect(folds.length).to.equal(1)
|
||||
expect(folds).to.deep.equal([{ from: 8, to: 17 }])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,786 @@
|
||||
import { assert } from 'chai'
|
||||
import LintWorker from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js'
|
||||
import { errorsToDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics'
|
||||
import { Diagnostic } from '@codemirror/lint'
|
||||
import { mergeCompatibleOverlappingDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics'
|
||||
|
||||
const { Parse } = new LintWorker()
|
||||
|
||||
describe('LatexLinter', function () {
|
||||
it('should accept a simple environment match without errors', function () {
|
||||
const { errors } = Parse('\\begin{foo}\n' + '\\end{foo}\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept an invalid \\it* command', function () {
|
||||
const { errors } = Parse('\\it*hello\n' + '\\bye\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept newcomlumntype', function () {
|
||||
const { errors } = Parse(
|
||||
'hello\n' +
|
||||
'\\newcolumntype{M}[1]{>{\\begin{varwidth}[t]{#1}}l<{\\end{varwidth}}}\n' +
|
||||
'bye'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept newenvironment', function () {
|
||||
const { errors } = Parse(
|
||||
'\\newenvironment{Algorithm}[2][tbh]%\n' +
|
||||
'{\\begin{myalgo}[#1]\n' +
|
||||
'\\centering\n' +
|
||||
'\\part{title}\\begin{minipage}{#2}\n' +
|
||||
'\\begin{algorithm}[H]}%\n' +
|
||||
'{\\end{algorithm}\n' +
|
||||
'\\end{minipage}\n' +
|
||||
'\\end{myalgo}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept newenvironment II', function () {
|
||||
const { errors } = Parse(
|
||||
'\\newenvironment{claimproof}[1][\\myproofname]{\\begin{proof}[#1]\\renewcommand*{\\qedsymbol}{\\(\\diamondsuit\\)}}{\\end{proof}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept superscript inside math mode', function () {
|
||||
const { errors } = Parse('this is $a^b$ test')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept subscript inside math mode', function () {
|
||||
const { errors } = Parse('this is $a_b$ test')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should return an error for superscript outside math mode', function () {
|
||||
const { errors } = Parse('this is a^b test')
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(errors[0].text, '^ must be inside math mode')
|
||||
assert.equal(errors[0].type, 'error')
|
||||
})
|
||||
|
||||
it('should return an error subscript outside math mode', function () {
|
||||
const { errors } = Parse('this is a_b test')
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(errors[0].text, '_ must be inside math mode')
|
||||
assert.equal(errors[0].type, 'error')
|
||||
})
|
||||
|
||||
it('should accept math mode inside \\hbox outside math mode', function () {
|
||||
const { errors } = Parse('this is \\hbox{for every $bar$}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math mode inside \\hbox inside math mode', function () {
|
||||
const { errors } = Parse('this is $foo = \\hbox{for every $bar$}$ test')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math mode inside \\text inside math mode', function () {
|
||||
const { errors } = Parse('this is $foo = \\text{for every $bar$}$ test')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept verbatim', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{verbatim}\n' +
|
||||
'this is verbatim\n' +
|
||||
'\\end{verbatim}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept verbatim with environment inside', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{verbatim}\n' +
|
||||
'this is verbatim\n' +
|
||||
'\\begin{foo}\n' +
|
||||
'this is verbatim too\n' +
|
||||
'\\end{foo}\n' +
|
||||
'\\end{verbatim}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept verbatim with \\begin{verbatim} inside', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{verbatim}\n' +
|
||||
'this is verbatim\n' +
|
||||
'\\begin{verbatim}\n' +
|
||||
'this is verbatim too\n' +
|
||||
'\\end{verbatim}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept equation', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{equation}\n' +
|
||||
'\\alpha^2 + b^2 = c^2\n' +
|
||||
'\\end{equation}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept $$', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'$$\n' +
|
||||
'\\alpha^2 + b^2 = c^2\n' +
|
||||
'$$\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept $', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text $\\alpha^2 + b^2 = c^2$' + ' this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\[', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\[\n' +
|
||||
'\\alpha^2 + b^2 = c^2\n' +
|
||||
'\\]\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\(', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\(\\alpha^2 + b^2 = c^2\\)' + ' this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\begin{foo}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{foo}\n' +
|
||||
'this is foo\n' +
|
||||
'\\end{foo}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\begin{foo_bar}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{foo_bar}\n' +
|
||||
'this is foo bar\n' +
|
||||
'\\end{foo_bar}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\begin{foo} \\begin{bar}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{foo}\n' +
|
||||
'\\begin{bar}\n' +
|
||||
'\\begin{baz}\n' +
|
||||
'this is foo bar baz\n' +
|
||||
'\\end{baz}\n' +
|
||||
'\\end{bar}\n' +
|
||||
'\\end{foo}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\verb|...|', function () {
|
||||
const { errors } = Parse('this is text \\verb|hello| and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\verb|...| with special chars', function () {
|
||||
const { errors } = Parse('this is text \\verb|{}()^_@$xhello| and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\url|...|', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\url|http://www.overleaf.com/| and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\url{...}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\url{http://www.overleaf.com/} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\url{...} with % chars', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\url{http://www.overleaf.com/hello%20world} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\href{...}{...}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\href{http://www.overleaf.com/}{test} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\href{...}{...} with dollarsign in url', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\href{http://www.overleaf.com/foo=$bar}{test} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should not accept \\href|...|{...}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\href|http://www.overleaf.com|{test} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(errors[0].text, 'invalid href command')
|
||||
assert.equal(errors[0].type, 'error')
|
||||
})
|
||||
|
||||
it('should catch error in text argument of \\href{...}{...}', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text \\href{http://www.overleaf.com/foo=$bar}{i have made an $error} and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 2)
|
||||
assert.equal(errors[0].text, 'unclosed $ found at close group }')
|
||||
assert.equal(errors[0].type, 'error')
|
||||
assert.equal(errors[1].text, 'unexpected close group } after $')
|
||||
assert.equal(errors[1].type, 'error')
|
||||
})
|
||||
|
||||
it('should accept \\left( and \\right)', function () {
|
||||
const { errors } = Parse('math $\\left( x + y \\right) = y + x$ and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\left( and \\right.', function () {
|
||||
const { errors } = Parse('math $\\left( x + y \\right. = y + x$ and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept \\left. and \\right)', function () {
|
||||
const { errors } = Parse('math $\\left. x + y \\right) = y + x$ and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept complex math nesting', function () {
|
||||
const { errors } = Parse(
|
||||
'math $\\left( {x + {y + z} + x} \\right\\} = \\left[y + x\\right.$ and more\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math toggling $a$$b$', function () {
|
||||
const { errors } = Parse('math $a$$b$ and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math toggling $$display$$$inline$', function () {
|
||||
const { errors } = Parse('math $$display$$$inline$ and more\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math definition commands', function () {
|
||||
const { errors } = Parse(
|
||||
'\\let\\originalleft\\left\n' +
|
||||
'\\let\\originalright\\right\n' +
|
||||
'\\renewcommand{\\left}{\\mathopen{}\\mathclose\\bgroup\\originalleft}\n' +
|
||||
'\\renewcommand{\\right}{\\aftergroup\\egroup\\originalright}\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math reflectbox commands', function () {
|
||||
const { errors } = Parse('$\\reflectbox{$alpha$}$\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math scalebox commands', function () {
|
||||
const { errors } = Parse('$\\scalebox{2}{$alpha$}$\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math rotatebox commands', function () {
|
||||
const { errors } = Parse('$\\rotatebox{60}{$alpha$}$\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math resizebox commands', function () {
|
||||
const { errors } = Parse('$\\resizebox{2}{3}{$alpha$}$\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept all math box commands', function () {
|
||||
const { errors } = Parse(
|
||||
'\\[ \\left(\n' +
|
||||
'\\shiftright{2ex}{\\raisebox{-2ex}{\\scalebox{2}{$\\ast$}}}\n' +
|
||||
'\\reflectbox{$ddots$}\n' +
|
||||
'\\right). \\]\n'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math tag commands', function () {
|
||||
const { errors } = Parse('$\\tag{$alpha$}$\n')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math \\def commands', function () {
|
||||
const { errors } = Parse(
|
||||
'\\def\\peb[#1]{{\\left\\lfloor #1\\right\\rfloor}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept math \\def commands II', function () {
|
||||
const { errors } = Parse('\\def\\foo#1{\\gamma^#1}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept DeclareMathOperator', function () {
|
||||
const { errors } = Parse('\\DeclareMathOperator{\\var}{\\Delta^2\\!}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept DeclarePairedDelimiter', function () {
|
||||
const { errors } = Parse(
|
||||
'\\DeclarePairedDelimiter{\\spro}{\\left(}{\\right)^{\\ast}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept nested user-defined math commands', function () {
|
||||
const { errors } = Parse(
|
||||
'$\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}$'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept nested user-defined math commands II', function () {
|
||||
const { errors } = Parse(
|
||||
'\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept newenvironment with multiple parameters', function () {
|
||||
const { errors } = Parse(
|
||||
'\\newenvironment{case}[1][\\textsc{Case}]\n' +
|
||||
'{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept newenvironment with no parameters', function () {
|
||||
const { errors } = Parse(
|
||||
'\\newenvironment{case}{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept tikzfeynman', function () {
|
||||
const { errors } = Parse(
|
||||
'\\begin{equation*}\n' +
|
||||
'\\feynmandiagram[layered layout, medium, horizontal=a to b] {\n' +
|
||||
' a [particle=\\(H\\)] -- [scalar] b [dot] -- [photon] f1 [particle=\\(W^{\\pm}\\)],\n' +
|
||||
' b -- [boson, edge label=\\(W^{\\mp}\\)] c [dot],\n' +
|
||||
' c -- [fermion] f2 [particle=\\(f\\)],\n' +
|
||||
" c -- [anti fermion] f3 [particle=\\(\\bar{f}'\\)],\n" +
|
||||
' };this is a change\n' +
|
||||
'\\end{equation*}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should return errors from malformed \\end', function () {
|
||||
const { errors } = Parse(
|
||||
'this is text\n' +
|
||||
'\\begin{foo}\n' +
|
||||
'\\begin{bar}\n' +
|
||||
'this is foo bar baz\n' +
|
||||
'\\end{bar\n' +
|
||||
'\\end{foo}\n' +
|
||||
'this is more text\n'
|
||||
)
|
||||
assert.equal(errors.length, 4)
|
||||
assert.equal(errors[0].text, 'unclosed \\begin{bar} found at \\end{foo}')
|
||||
assert.equal(errors[1].text, 'invalid environment command \\end{bar')
|
||||
assert.equal(errors[2].text, 'unclosed open group { found at \\end{foo}')
|
||||
assert.equal(errors[3].text, 'unexpected \\end{foo} after \\begin{bar}')
|
||||
})
|
||||
|
||||
it('should accept \\newcommand*', function () {
|
||||
const { errors } = Parse('\\newcommand*{\\foo}{\\bar}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept incomplete \\newcommand*', function () {
|
||||
const { errors } = Parse('\\newcommand*{\\beq' + '}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept a plain hyperref command', function () {
|
||||
const { errors } = Parse('\\hyperref{http://www.overleaf.com/}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept a hyperref command with underscores in the url ', function () {
|
||||
const { errors } = Parse('\\hyperref{http://www.overleaf.com/my_page.html}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept a hyperref command with category, name and text arguments ', function () {
|
||||
const { errors } = Parse(
|
||||
'\\hyperref{http://www.overleaf.com/}{category}{name}{text}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept an underscore in a hyperref label', function () {
|
||||
const { errors } = Parse('\\hyperref[foo_bar]{foo bar}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should reject a $ in a hyperref label', function () {
|
||||
const { errors } = Parse('\\hyperref[foo$bar]{foo bar}')
|
||||
assert.equal(errors.length, 1)
|
||||
})
|
||||
|
||||
it('should reject an unclosed hyperref label', function () {
|
||||
const { errors } = Parse('\\hyperref[foo_bar{foo bar}')
|
||||
assert.equal(errors.length, 2)
|
||||
assert.equal(errors[0].text, 'invalid hyperref label')
|
||||
assert.equal(errors[1].text, 'unexpected close group }')
|
||||
})
|
||||
|
||||
it('should accept a hyperref command without an optional argument', function () {
|
||||
const { errors } = Parse('{\\hyperref{hello}}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept a hyperref command without an optional argument and multiple other arguments', function () {
|
||||
const { errors } = Parse('{\\hyperref{}{}{fig411}}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept a hyperref command without an optional argument in an unclosed group', function () {
|
||||
const { errors } = Parse('{\\hyperref{}{}{fig411}')
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(errors[0].text, 'unclosed group {')
|
||||
})
|
||||
|
||||
it('should accept documentclass with no options', function () {
|
||||
const { errors } = Parse('\\documentclass{article}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept documentclass with options', function () {
|
||||
const { errors } = Parse('\\documentclass[a4paper]{article}')
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
it('should accept documentclass with underscore in options', function () {
|
||||
const { errors } = Parse(
|
||||
'\\documentclass[my_custom_document_class_option]{my-custom-class}'
|
||||
)
|
||||
assert.equal(errors.length, 0)
|
||||
})
|
||||
|
||||
// %novalidate
|
||||
// %begin novalidate
|
||||
// %end novalidate
|
||||
// \begin{foo}
|
||||
// \begin{new_theorem}
|
||||
// \begin{foo invalid environment command
|
||||
// \newcommand{\foo}{\bar}
|
||||
// \newcommand[1]{\foo}{\bar #1}
|
||||
// \renewcommand...
|
||||
// \def
|
||||
// \DeclareRobustCommand
|
||||
// \newcolumntype
|
||||
// \newenvironment
|
||||
// \renewenvironment
|
||||
// \verb|....|
|
||||
// \url|...|
|
||||
// \url{...}
|
||||
// \left( \right)
|
||||
// \left. \right.
|
||||
// $...$
|
||||
// $$....$$
|
||||
// $...$$...$
|
||||
// $a^b$ vs a^b
|
||||
// $$a^b$$ vs a^b
|
||||
// Matrix for envs for {} left/right \[ \] \( \) $ $$ begin end
|
||||
// begin equation
|
||||
// align(*)
|
||||
// equation(*)
|
||||
// ]
|
||||
// array(*)
|
||||
// eqnarray(*)
|
||||
// split
|
||||
// aligned
|
||||
// cases
|
||||
// pmatrix
|
||||
// gathered
|
||||
// matrix
|
||||
// alignedat
|
||||
// smallmatrix
|
||||
// subarray
|
||||
// vmatrix
|
||||
// shortintertext
|
||||
|
||||
it('should return math mode contexts', function () {
|
||||
const { contexts } = Parse(
|
||||
'\\begin{document}\n' +
|
||||
'$$\n' +
|
||||
'\\begin{array}\n' +
|
||||
'\\left( \\foo{bar} \\right] & 2\n' +
|
||||
'\\end{array}\n' +
|
||||
'$$\n' +
|
||||
'\\end{document}'
|
||||
)
|
||||
assert.equal(contexts.length, 1)
|
||||
assert.equal(contexts[0].type, 'math')
|
||||
assert.equal(contexts[0].range.start.row, 1)
|
||||
assert.equal(contexts[0].range.start.column, 0)
|
||||
assert.equal(contexts[0].range.end.row, 5)
|
||||
assert.equal(contexts[0].range.end.column, 2)
|
||||
})
|
||||
|
||||
it('should remove error when cursor is inside incomplete command', function () {
|
||||
const { errors } = Parse('\\begin{}')
|
||||
const diagnostics = errorsToDiagnostics(errors, 7, 9)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(diagnostics.length, 0)
|
||||
})
|
||||
|
||||
it('should show an error when cursor is outside incomplete command', function () {
|
||||
const { errors } = Parse('\\begin{}')
|
||||
const diagnostics = errorsToDiagnostics(errors, 6, 9)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(diagnostics.length, 1)
|
||||
assert.equal(diagnostics[0].from, 0)
|
||||
assert.equal(diagnostics[0].to, 6)
|
||||
})
|
||||
|
||||
it('should adjust an error range when the cursor is inside that range', function () {
|
||||
const { errors } = Parse('\\begin{}')
|
||||
const diagnostics = errorsToDiagnostics(errors, 4, 7)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(errors[0].startPos, 0)
|
||||
assert.equal(errors[0].endPos, 7)
|
||||
assert.equal(diagnostics.length, 1)
|
||||
assert.equal(diagnostics[0].from, 0)
|
||||
assert.equal(diagnostics[0].to, 4)
|
||||
})
|
||||
|
||||
it('should reject an error when part of the error range is outside of the document boundaries', function () {
|
||||
const { errors } = Parse('\\begin{}')
|
||||
const diagnostics = errorsToDiagnostics(errors, 8, 6)
|
||||
assert.equal(errors.length, 1)
|
||||
assert.equal(diagnostics.length, 0)
|
||||
})
|
||||
|
||||
it('should merge two overlapping identical diagnostics', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(mergedDiagnostics, [
|
||||
{
|
||||
from: 0,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should merge two touching identical diagnostics', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 2,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(mergedDiagnostics, [
|
||||
{
|
||||
from: 0,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should not merge two overlapping diagnostics with different messages', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 3,
|
||||
message: 'Message 2',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(diagnostics, mergedDiagnostics)
|
||||
})
|
||||
|
||||
it('should not merge two overlapping diagnostics with different severities', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'warning',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(diagnostics, mergedDiagnostics)
|
||||
})
|
||||
|
||||
it('should merge three overlapping identical diagnostics', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 1,
|
||||
to: 4,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 3,
|
||||
to: 5,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(mergedDiagnostics, [
|
||||
{
|
||||
from: 0,
|
||||
to: 5,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should merge two separate sets of overlapping identical diagnostics', function () {
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
from: 0,
|
||||
to: 2,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 2,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 2,
|
||||
to: 5,
|
||||
message: 'Message 2',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 6,
|
||||
message: 'Message 3',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 5,
|
||||
to: 7,
|
||||
message: 'Message 3',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics)
|
||||
assert.deepEqual(mergedDiagnostics, [
|
||||
{
|
||||
from: 0,
|
||||
to: 3,
|
||||
message: 'Message 1',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 2,
|
||||
to: 5,
|
||||
message: 'Message 2',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 7,
|
||||
message: 'Message 3',
|
||||
severity: 'error',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,537 @@
|
||||
import { LanguageSupport } from '@codemirror/language'
|
||||
import { EditorState, Text } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { expect } from 'chai'
|
||||
import { documentOutline } from '../../../../../../frontend/js/features/source-editor/languages/latex/document-outline'
|
||||
import {
|
||||
FlatOutline,
|
||||
getNestingLevel,
|
||||
} from '../../../../../../frontend/js/features/source-editor/utils/tree-query'
|
||||
import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language'
|
||||
import {
|
||||
Book,
|
||||
Chapter,
|
||||
Paragraph,
|
||||
Part,
|
||||
Section,
|
||||
SubParagraph,
|
||||
SubSection,
|
||||
SubSubSection,
|
||||
} from '../../../../../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs'
|
||||
|
||||
const latex = new LanguageSupport(LaTeXLanguage, documentOutline.extension)
|
||||
|
||||
const makeView = (lines: string[]): EditorView => {
|
||||
const text = Text.of(lines)
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text,
|
||||
extensions: [latex],
|
||||
}),
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
const BOOK_LEVEL = getNestingLevel(Book)
|
||||
const PART_LEVEL = getNestingLevel(Part)
|
||||
const CHAPTER_LEVEL = getNestingLevel(Chapter)
|
||||
const SECTION_LEVEL = getNestingLevel(Section)
|
||||
const SUB_SECTION_LEVEL = getNestingLevel(SubSection)
|
||||
const SUB_SUB_SECTION_LEVEL = getNestingLevel(SubSubSection)
|
||||
const PARAGRAPH_LEVEL = getNestingLevel(Paragraph)
|
||||
const SUB_PARAGRAPH_LEVEL = getNestingLevel(SubParagraph)
|
||||
const FRAME_LEVEL = getNestingLevel('frame')
|
||||
|
||||
const insertText = (view: EditorView, position: number, text: string) => {
|
||||
view.dispatch({
|
||||
changes: [{ from: position, insert: text }],
|
||||
})
|
||||
}
|
||||
|
||||
const deleteText = (view: EditorView, position: number, length: number) => {
|
||||
view.dispatch({
|
||||
changes: [{ from: position - length, to: position }],
|
||||
})
|
||||
}
|
||||
|
||||
const getOutline = (view: EditorView): FlatOutline | null => {
|
||||
return view.state.field(documentOutline)?.items || null
|
||||
}
|
||||
|
||||
describe('CodeMirror LaTeX-FileOutline', function () {
|
||||
describe('with no update', function () {
|
||||
describe('an empty document', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should have empty outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
describe('a document with nested sections', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'line 1',
|
||||
'\\section{sec title}',
|
||||
'content',
|
||||
'\\subsection{subsec title}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should have outline with different levels', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.be.deep.equal([
|
||||
{
|
||||
from: 7,
|
||||
to: 26,
|
||||
level: SECTION_LEVEL,
|
||||
title: 'sec title',
|
||||
line: 2,
|
||||
},
|
||||
{
|
||||
from: 35,
|
||||
to: 60,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
title: 'subsec title',
|
||||
line: 4,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a document with sibling sections', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'line 1',
|
||||
'\\section{sec title 1}',
|
||||
'content',
|
||||
'\\section{sec title 2}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should have outline with same levels for siblings', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.be.deep.equal([
|
||||
{
|
||||
from: 7,
|
||||
to: 28,
|
||||
level: SECTION_LEVEL,
|
||||
title: 'sec title 1',
|
||||
line: 2,
|
||||
},
|
||||
{
|
||||
from: 37,
|
||||
to: 58,
|
||||
level: SECTION_LEVEL,
|
||||
title: 'sec title 2',
|
||||
line: 4,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with change to title', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\section{title }']
|
||||
view = makeView(content)
|
||||
const initialOutline = getOutline(view)
|
||||
expect(initialOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 16,
|
||||
title: 'title ',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('for appending to title', function () {
|
||||
beforeEach(function () {
|
||||
insertText(view, 15, '1')
|
||||
})
|
||||
|
||||
it('should update title in outline', function () {
|
||||
const updatedOutline = getOutline(view)
|
||||
expect(updatedOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 17,
|
||||
title: 'title 1',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for removing from title', function () {
|
||||
beforeEach(function () {
|
||||
deleteText(view, 15, 1)
|
||||
})
|
||||
|
||||
it('should update title in outline', function () {
|
||||
const updatedOutline = getOutline(view)
|
||||
expect(updatedOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 15,
|
||||
title: 'title',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('for moving section', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\section{title}', '\\subsection{subtitle}']
|
||||
view = makeView(content)
|
||||
const initialOutline = getOutline(view)
|
||||
expect(initialOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 15,
|
||||
title: 'title',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 16,
|
||||
to: 37,
|
||||
title: 'subtitle',
|
||||
line: 2,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
insertText(view, 15, '\n')
|
||||
})
|
||||
|
||||
it('should update position for moved section', function () {
|
||||
const updatedOutline = getOutline(view)
|
||||
expect(updatedOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 15,
|
||||
title: 'title',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 17,
|
||||
to: 38,
|
||||
title: 'subtitle',
|
||||
line: 3,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for removing a section', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\section{title}']
|
||||
view = makeView(content)
|
||||
const initialOutline = getOutline(view)
|
||||
expect(initialOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 15,
|
||||
title: 'title',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
deleteText(view, 4, 1)
|
||||
})
|
||||
|
||||
it('should remove the section from the outline', function () {
|
||||
const updatedOutline = getOutline(view)
|
||||
expect(updatedOutline).to.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
describe('for changing parent section', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'\\section{section}',
|
||||
'%\\subsection{subsection}', // initially commented out
|
||||
'\\subsubsection{subsubsection}',
|
||||
]
|
||||
view = makeView(content)
|
||||
const initialOutline = getOutline(view)
|
||||
expect(initialOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 17,
|
||||
title: 'section',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 43,
|
||||
to: 72,
|
||||
title: 'subsubsection',
|
||||
line: 3,
|
||||
level: SUB_SUB_SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
// Remove the %
|
||||
deleteText(view, 19, 1)
|
||||
})
|
||||
|
||||
it('should be nested properly', function () {
|
||||
const updatedOutline = getOutline(view)
|
||||
expect(updatedOutline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 17,
|
||||
title: 'section',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 18,
|
||||
to: 41,
|
||||
title: 'subsection',
|
||||
line: 2,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 42,
|
||||
to: 71,
|
||||
title: 'subsubsection',
|
||||
line: 3,
|
||||
level: SUB_SUB_SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for a sectioning command inside a newcommand or renewcommand', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'\\section{section}',
|
||||
'\\newcommand{\\test}{\\section{should not display}}',
|
||||
'\\renewcommand{\\test}{\\section{should still not display}}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
it('should not include them in the outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline?.length).to.equal(1)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 17,
|
||||
title: 'section',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for all section types', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'\\book{book}',
|
||||
'\\part{part}',
|
||||
'\\chapter{chapter}',
|
||||
'\\section{section}',
|
||||
'\\subsection{subsection}',
|
||||
'\\subsubsection{subsubsection}',
|
||||
'\\paragraph{paragraph}',
|
||||
'\\subparagraph{subparagraph}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should include them in the file outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 11,
|
||||
title: 'book',
|
||||
line: 1,
|
||||
level: BOOK_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 12,
|
||||
to: 23,
|
||||
title: 'part',
|
||||
line: 2,
|
||||
level: PART_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 24,
|
||||
to: 41,
|
||||
title: 'chapter',
|
||||
line: 3,
|
||||
level: CHAPTER_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 42,
|
||||
to: 59,
|
||||
title: 'section',
|
||||
line: 4,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 60,
|
||||
to: 83,
|
||||
title: 'subsection',
|
||||
line: 5,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 84,
|
||||
to: 113,
|
||||
title: 'subsubsection',
|
||||
line: 6,
|
||||
level: SUB_SUB_SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 114,
|
||||
to: 135,
|
||||
title: 'paragraph',
|
||||
line: 7,
|
||||
level: PARAGRAPH_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 136,
|
||||
to: 163,
|
||||
title: 'subparagraph',
|
||||
line: 8,
|
||||
level: SUB_PARAGRAPH_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectioning commands with optional arguments', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\section[short title]{section}']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should use the long argument as title', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 30,
|
||||
title: 'section',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for labels using texorpdfstring', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = [
|
||||
'\\section{The \\texorpdfstring{function $f(x) = x^2$}{function f(x) = x^2}: Properties of \\texorpdfstring{$x$}{x}.}',
|
||||
]
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should use the text argument as title', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 113,
|
||||
title: 'The function f(x) = x^2: Properties of x.',
|
||||
line: 1,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for ill-formed \\def command', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\def\\x{', '\\section{test}', '\\subsection{test2}']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('still shows an outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 8,
|
||||
to: 22,
|
||||
title: 'test',
|
||||
line: 2,
|
||||
level: SECTION_LEVEL,
|
||||
},
|
||||
{
|
||||
from: 23,
|
||||
to: 41,
|
||||
title: 'test2',
|
||||
line: 3,
|
||||
level: SUB_SECTION_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('for beamer frames', function () {
|
||||
describe('with titles', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\begin{frame}{frame title}{}', '\\end{frame}']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should show up in the file outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.deep.equal([
|
||||
{
|
||||
from: 0,
|
||||
to: 28,
|
||||
title: 'frame title',
|
||||
line: 1,
|
||||
level: FRAME_LEVEL,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('without titles', function () {
|
||||
let view: EditorView, content: string[]
|
||||
beforeEach(function () {
|
||||
content = ['\\begin{frame}', '\\end{frame}']
|
||||
view = makeView(content)
|
||||
})
|
||||
|
||||
it('should not show up in the file outline', function () {
|
||||
const outline = getOutline(view)
|
||||
expect(outline).to.be.empty
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user